feat: eslint 적용 및 관련 코드 일괄 수정

This commit is contained in:
2026-01-05 15:46:47 +00:00
parent cb312f02b3
commit c965b1120f
387 changed files with 39808 additions and 38800 deletions
+11 -11
View File
@@ -1,13 +1,13 @@
<!DOCTYPE html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>삼국지 모의전투 HiDCHe - Gateway</title>
</head>
<body class="bg-black text-white">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>삼국지 모의전투 HiDCHe - Gateway</title>
</head>
<body class="bg-black text-white">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx --fix --ignore-path .gitignore",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
+1 -1
View File
@@ -3,7 +3,7 @@ import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
<RouterView />
</template>
<style>
+19 -5
View File
@@ -1,11 +1,25 @@
@import "tailwindcss";
@import 'tailwindcss';
@theme {
--color-sammo-dark: #1a1a1a;
--color-sammo-gold: #ffd700;
--color-sammo-dark: #1a1a1a;
--color-sammo-gold: #ffd700;
}
body {
@apply bg-black text-gray-200;
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif;
@apply bg-black text-gray-200;
font-family:
'Pretendard',
-apple-system,
BlinkMacSystemFont,
system-ui,
Roboto,
'Helvetica Neue',
'Segoe UI',
'Apple SD Gothic Neo',
'Noto Sans KR',
'Malgun Gothic',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
sans-serif;
}
+3 -3
View File
@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
@@ -1,45 +1,44 @@
<script setup lang="ts">
</script>
<script setup lang="ts"></script>
<template>
<div class="min-h-screen flex flex-col bg-black text-gray-200">
<!-- Header / Navigation -->
<header class="bg-zinc-900 border-b border-zinc-800 py-2 px-4">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center space-x-6">
<h1 class="text-xl font-bold text-white">삼국지 모의전투 HiDCHe</h1>
<nav class="hidden md:flex space-x-4 text-sm">
<a href="#" class="hover:text-white">공지사항</a>
<a href="#" class="hover:text-white">커뮤니티</a>
<a href="#" class="hover:text-white">건의/제안/개발</a>
<a href="#" class="hover:text-white">신고/문의</a>
<a href="#" class="hover:text-white">자주 묻는 질문</a>
<a href="#" class="hover:text-white">패치 내역</a>
<a href="#" class="hover:text-white">Git Repo.</a>
<a href="#" class="hover:text-white">위키</a>
<RouterLink to="/admin" class="hover:text-white">관리자</RouterLink>
</nav>
</div>
<div class="flex space-x-4 text-sm">
<a href="#" class="hover:text-white">공식 오픈 </a>
<a href="#" class="hover:text-white">잡담 오픈 </a>
</div>
</div>
</header>
<div class="min-h-screen flex flex-col bg-black text-gray-200">
<!-- Header / Navigation -->
<header class="bg-zinc-900 border-b border-zinc-800 py-2 px-4">
<div class="max-w-6xl mx-auto flex justify-between items-center">
<div class="flex items-center space-x-6">
<h1 class="text-xl font-bold text-white">삼국지 모의전투 HiDCHe</h1>
<nav class="hidden md:flex space-x-4 text-sm">
<a href="#" class="hover:text-white">공지사항</a>
<a href="#" class="hover:text-white">커뮤니티</a>
<a href="#" class="hover:text-white">건의/제안/개발</a>
<a href="#" class="hover:text-white">신고/문의</a>
<a href="#" class="hover:text-white">자주 묻는 질문</a>
<a href="#" class="hover:text-white">패치 내역</a>
<a href="#" class="hover:text-white">Git Repo.</a>
<a href="#" class="hover:text-white">위키</a>
<RouterLink to="/admin" class="hover:text-white">관리자</RouterLink>
</nav>
</div>
<div class="flex space-x-4 text-sm">
<a href="#" class="hover:text-white">공식 오픈 </a>
<a href="#" class="hover:text-white">잡담 오픈 </a>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-grow">
<slot />
</main>
<!-- Main Content -->
<main class="flex-grow">
<slot />
</main>
<!-- Footer -->
<footer class="bg-zinc-900 border-t border-zinc-800 py-6 px-4 text-center text-xs text-zinc-500">
<div class="space-x-4 mb-2">
<a href="#" class="hover:text-zinc-300">개인정보처리방침</a>
<a href="#" class="hover:text-zinc-300">이용약관</a>
</div>
<p>© 2023 HideD</p>
<p class="mt-1">크롬, 엣지, 파이어폭스에 최적화되어있습니다.</p>
</footer>
</div>
<!-- Footer -->
<footer class="bg-zinc-900 border-t border-zinc-800 py-6 px-4 text-center text-xs text-zinc-500">
<div class="space-x-4 mb-2">
<a href="#" class="hover:text-zinc-300">개인정보처리방침</a>
<a href="#" class="hover:text-zinc-300">이용약관</a>
</div>
<p>© 2023 HideD</p>
<p class="mt-1">크롬, 엣지, 파이어폭스에 최적화되어있습니다.</p>
</footer>
</div>
</template>
+18 -18
View File
@@ -4,24 +4,24 @@ import LobbyView from '../views/LobbyView.vue';
import AdminView from '../views/AdminView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/lobby',
name: 'lobby',
component: LobbyView,
},
{
path: '/admin',
name: 'admin',
component: AdminView,
},
],
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/lobby',
name: 'lobby',
component: LobbyView,
},
{
path: '/admin',
name: 'admin',
component: AdminView,
},
],
});
export default router;
+11 -11
View File
@@ -2,20 +2,20 @@ import { defineStore } from 'pinia';
import { ref } from 'vue';
export interface UserInfo {
id: string;
username: string;
displayName: string;
roles: string[];
id: string;
username: string;
displayName: string;
roles: string[];
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<UserInfo | null>(null);
const isLoggedIn = ref(false);
const user = ref<UserInfo | null>(null);
const isLoggedIn = ref(false);
function setUser(userData: UserInfo | null) {
user.value = userData;
isLoggedIn.value = !!userData;
}
function setUser(userData: UserInfo | null) {
user.value = userData;
isLoggedIn.value = !!userData;
}
return { user, isLoggedIn, setUser };
return { user, isLoggedIn, setUser };
});
+7 -7
View File
@@ -4,11 +4,11 @@ import type { appRouter } from '../../../game-api/src/router';
export type GameRouter = typeof appRouter;
export const createGameTrpc = (port: number) => {
return createTRPCProxyClient<GameRouter>({
links: [
httpBatchLink({
url: `http://localhost:${port}/api/trpc`, // 실제 환경에서는 도메인/경로 조정 필요
}),
],
});
return createTRPCProxyClient<GameRouter>({
links: [
httpBatchLink({
url: `http://localhost:${port}/api/trpc`, // 실제 환경에서는 도메인/경로 조정 필요
}),
],
});
};
+13 -13
View File
@@ -2,20 +2,20 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/gateway-api';
const getSessionToken = (): string | null => {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem('sammo-session-token');
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem('sammo-session-token');
};
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
headers() {
const token = getSessionToken();
return token ? { 'x-session-token': token } : {};
},
}),
],
links: [
httpBatchLink({
url: '/api/trpc', // 실제 환경에 맞게 조정 필요
headers() {
const token = getSessionToken();
return token ? { 'x-session-token': token } : {};
},
}),
],
});
+867 -850
View File
@@ -4,140 +4,153 @@ import DefaultLayout from '../layouts/DefaultLayout.vue';
import { trpc } from '../utils/trpc';
type AdminUserSanctions = {
bannedUntil?: string;
mutedUntil?: string;
suspendedUntil?: string;
warningCount?: number;
flags?: string[];
notes?: string;
profileIconResetAt?: string;
serverRestrictions?: Record<string, {
blockedFeatures?: string[];
until?: string;
reason?: string;
bannedUntil?: string;
mutedUntil?: string;
suspendedUntil?: string;
warningCount?: number;
flags?: string[];
notes?: string;
}>;
profileIconResetAt?: string;
serverRestrictions?: Record<
string,
{
blockedFeatures?: string[];
until?: string;
reason?: string;
notes?: string;
}
>;
};
type AdminSanctionsPatch = {
bannedUntil?: string | null;
mutedUntil?: string | null;
suspendedUntil?: string | null;
warningCount?: number | null;
flags?: string[] | null;
notes?: string | null;
profileIconResetAt?: string | null;
serverRestrictions?: Record<string, {
blockedFeatures?: string[];
until?: string | null;
reason?: string | null;
bannedUntil?: string | null;
mutedUntil?: string | null;
suspendedUntil?: string | null;
warningCount?: number | null;
flags?: string[] | null;
notes?: string | null;
} | null> | null;
profileIconResetAt?: string | null;
serverRestrictions?: Record<
string,
{
blockedFeatures?: string[];
until?: string | null;
reason?: string | null;
notes?: string | null;
} | null
> | null;
};
type AdminUser = {
id: string;
username: string;
displayName: string;
roles: string[];
sanctions: AdminUserSanctions;
oauthType: string;
oauthId?: string;
email?: string;
createdAt: string;
id: string;
username: string;
displayName: string;
roles: string[];
sanctions: AdminUserSanctions;
oauthType: string;
oauthId?: string;
email?: string;
createdAt: string;
};
type AdminProfile = {
profileName: string;
profile: string;
scenario: string;
status: string;
apiPort: number;
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
};
buildCommitSha?: string;
meta: Record<string, unknown>;
profileName: string;
profile: string;
scenario: string;
status: string;
apiPort: number;
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
};
buildCommitSha?: string;
meta: Record<string, unknown>;
};
type AdminAction =
| 'RESUME'
| 'PAUSE'
| 'STOP'
| 'ACCELERATE'
| 'DELAY'
| 'RESET_NOW'
| 'RESET_SCHEDULED'
| 'OPEN_SURVEY'
| 'SHUTDOWN';
| 'RESUME'
| 'PAUSE'
| 'STOP'
| 'ACCELERATE'
| 'DELAY'
| 'RESET_NOW'
| 'RESET_SCHEDULED'
| 'OPEN_SURVEY'
| 'SHUTDOWN';
type AdminClient = {
system: {
getNotice: {
query: () => Promise<{ notice: string }>;
};
setNotice: {
mutate: (input: { notice: string }) => Promise<{ notice: string }>;
};
};
users: {
lookup: {
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
};
resetPassword: {
mutate: (input: { userId: string; newPassword?: string }) => Promise<{ password: string }>;
};
updateRoles: {
mutate: (input: { userId: string; roles: string[]; mode?: 'set' | 'grant' | 'revoke' }) => Promise<{ roles: string[] }>;
};
updateSanctions: {
mutate: (input: { userId: string; patch: AdminSanctionsPatch }) => Promise<{ sanctions: AdminUserSanctions }>;
};
setServerRestriction: {
mutate: (input: {
userId: string;
profile: string;
restriction: {
blockedFeatures?: string[];
until?: string | null;
reason?: string | null;
notes?: string | null;
} | null;
}) => Promise<{ sanctions: AdminUserSanctions }>;
};
resetProfileIcon: {
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
};
forceDelete: {
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
};
};
profiles: {
list: {
query: () => Promise<AdminProfile[]>;
};
updateMeta: {
mutate: (input: {
profileName: string;
patch: {
korName?: string | null;
color?: string | null;
inGameNotice?: string | null;
profileImageUrl?: string | null;
system: {
getNotice: {
query: () => Promise<{ notice: string }>;
};
setNotice: {
mutate: (input: { notice: string }) => Promise<{ notice: string }>;
};
}) => Promise<AdminProfile | null>;
};
requestAction: {
mutate: (input: {
profileName: string;
action: AdminAction;
durationMinutes?: number;
scheduledAt?: string;
reason?: string;
}) => Promise<{ ok: boolean }>;
users: {
lookup: {
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
};
resetPassword: {
mutate: (input: { userId: string; newPassword?: string }) => Promise<{ password: string }>;
};
updateRoles: {
mutate: (input: {
userId: string;
roles: string[];
mode?: 'set' | 'grant' | 'revoke';
}) => Promise<{ roles: string[] }>;
};
updateSanctions: {
mutate: (input: {
userId: string;
patch: AdminSanctionsPatch;
}) => Promise<{ sanctions: AdminUserSanctions }>;
};
setServerRestriction: {
mutate: (input: {
userId: string;
profile: string;
restriction: {
blockedFeatures?: string[];
until?: string | null;
reason?: string | null;
notes?: string | null;
} | null;
}) => Promise<{ sanctions: AdminUserSanctions }>;
};
resetProfileIcon: {
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
};
forceDelete: {
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
};
};
profiles: {
list: {
query: () => Promise<AdminProfile[]>;
};
updateMeta: {
mutate: (input: {
profileName: string;
patch: {
korName?: string | null;
color?: string | null;
inGameNotice?: string | null;
profileImageUrl?: string | null;
};
}) => Promise<AdminProfile | null>;
};
requestAction: {
mutate: (input: {
profileName: string;
action: AdminAction;
durationMinutes?: number;
scheduledAt?: string;
reason?: string;
}) => Promise<{ ok: boolean }>;
};
};
};
};
const adminClient = trpc.admin as unknown as AdminClient;
@@ -146,19 +159,19 @@ const sessionToken = ref('');
const sessionTokenStatus = ref('');
if (typeof window !== 'undefined') {
sessionToken.value = window.localStorage.getItem('sammo-session-token') ?? '';
sessionToken.value = window.localStorage.getItem('sammo-session-token') ?? '';
}
const saveSessionToken = () => {
const value = sessionToken.value.trim();
if (typeof window !== 'undefined') {
if (value) {
window.localStorage.setItem('sammo-session-token', value);
} else {
window.localStorage.removeItem('sammo-session-token');
const value = sessionToken.value.trim();
if (typeof window !== 'undefined') {
if (value) {
window.localStorage.setItem('sammo-session-token', value);
} else {
window.localStorage.removeItem('sammo-session-token');
}
}
}
sessionTokenStatus.value = value ? '저장됨' : '삭제됨';
sessionTokenStatus.value = value ? '저장됨' : '삭제됨';
};
const noticeDraft = ref('');
@@ -167,17 +180,27 @@ const noticeLoading = ref(false);
const profiles = ref<AdminProfile[]>([]);
const profilesLoading = ref(false);
const profileEdits = ref<Record<string, {
korName: string;
color: string;
inGameNotice: string;
profileImageUrl: string;
}>>({});
const profileActions = ref<Record<string, {
durationMinutes: string;
scheduledAt: string;
reason: string;
}>>({});
const profileEdits = ref<
Record<
string,
{
korName: string;
color: string;
inGameNotice: string;
profileImageUrl: string;
}
>
>({});
const profileActions = ref<
Record<
string,
{
durationMinutes: string;
scheduledAt: string;
reason: string;
}
>
>({});
const profileActionStatus = ref<Record<string, string>>({});
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
@@ -212,774 +235,768 @@ const forceDeleteStatus = ref('');
const hasUser = computed(() => Boolean(userResult.value));
const loadNotice = async () => {
noticeLoading.value = true;
try {
const result = await adminClient.system.getNotice.query();
noticeDraft.value = result.notice;
} catch (error) {
noticeStatus.value = '공지 불러오기 실패';
} finally {
noticeLoading.value = false;
}
noticeLoading.value = true;
try {
const result = await adminClient.system.getNotice.query();
noticeDraft.value = result.notice;
} catch (error) {
noticeStatus.value = '공지 불러오기 실패';
} finally {
noticeLoading.value = false;
}
};
const saveNotice = async () => {
noticeLoading.value = true;
noticeStatus.value = '';
try {
const result = await adminClient.system.setNotice.mutate({
notice: noticeDraft.value,
});
noticeDraft.value = result.notice;
noticeStatus.value = '저장 완료';
} catch (error) {
noticeStatus.value = '저장 실패';
} finally {
noticeLoading.value = false;
}
noticeLoading.value = true;
noticeStatus.value = '';
try {
const result = await adminClient.system.setNotice.mutate({
notice: noticeDraft.value,
});
noticeDraft.value = result.notice;
noticeStatus.value = '저장 완료';
} catch (error) {
noticeStatus.value = '저장 실패';
} finally {
noticeLoading.value = false;
}
};
const ensureProfileBuffers = (profile: AdminProfile) => {
if (!profileEdits.value[profile.profileName]) {
const meta = (profile.meta ?? {}) as Record<string, unknown>;
profileEdits.value[profile.profileName] = {
korName: String(meta.korName ?? profile.profile),
color: String(meta.color ?? '#ffffff'),
inGameNotice: String(meta.inGameNotice ?? ''),
profileImageUrl: String(meta.profileImageUrl ?? ''),
};
}
if (!profileActions.value[profile.profileName]) {
profileActions.value[profile.profileName] = {
durationMinutes: '',
scheduledAt: '',
reason: '',
};
}
if (!profileEdits.value[profile.profileName]) {
const meta = (profile.meta ?? {}) as Record<string, unknown>;
profileEdits.value[profile.profileName] = {
korName: String(meta.korName ?? profile.profile),
color: String(meta.color ?? '#ffffff'),
inGameNotice: String(meta.inGameNotice ?? ''),
profileImageUrl: String(meta.profileImageUrl ?? ''),
};
}
if (!profileActions.value[profile.profileName]) {
profileActions.value[profile.profileName] = {
durationMinutes: '',
scheduledAt: '',
reason: '',
};
}
};
const loadProfiles = async () => {
profilesLoading.value = true;
try {
const result = await adminClient.profiles.list.query();
result.forEach(ensureProfileBuffers);
profiles.value = result;
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
global: '프로필 목록을 불러오지 못했습니다.',
};
} finally {
profilesLoading.value = false;
}
profilesLoading.value = true;
try {
const result = await adminClient.profiles.list.query();
result.forEach(ensureProfileBuffers);
profiles.value = result;
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
global: '프로필 목록을 불러오지 못했습니다.',
};
} finally {
profilesLoading.value = false;
}
};
const updateProfileMeta = async (profileName: string) => {
const edit = profileEdits.value[profileName];
if (!edit) {
return;
}
const patch = {
korName: edit.korName.trim() || null,
color: edit.color.trim() || null,
inGameNotice: edit.inGameNotice.trim() || null,
profileImageUrl: edit.profileImageUrl.trim() || null,
};
try {
const updated = await adminClient.profiles.updateMeta.mutate({
profileName,
patch,
});
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: updated ? '메타 저장 완료' : '메타 저장 실패',
};
if (updated) {
profiles.value = profiles.value.map((item) =>
item.profileName === profileName ? { ...item, ...updated } : item
);
const edit = profileEdits.value[profileName];
if (!edit) {
return;
}
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '메타 저장 실패',
const patch = {
korName: edit.korName.trim() || null,
color: edit.color.trim() || null,
inGameNotice: edit.inGameNotice.trim() || null,
profileImageUrl: edit.profileImageUrl.trim() || null,
};
}
try {
const updated = await adminClient.profiles.updateMeta.mutate({
profileName,
patch,
});
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: updated ? '메타 저장 완료' : '메타 저장 실패',
};
if (updated) {
profiles.value = profiles.value.map((item) =>
item.profileName === profileName ? { ...item, ...updated } : item
);
}
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '메타 저장 실패',
};
}
};
const requestProfileAction = async (profileName: string, action: AdminAction) => {
const actionState = profileActions.value[profileName];
const durationMinutes = actionState?.durationMinutes
? Number(actionState.durationMinutes)
: undefined;
const durationValue =
durationMinutes && durationMinutes > 0 ? durationMinutes : undefined;
const scheduledAt = actionState?.scheduledAt
? new Date(actionState.scheduledAt).toISOString()
: undefined;
const reason = actionState?.reason.trim() || undefined;
try {
await adminClient.profiles.requestAction.mutate({
profileName,
action,
durationMinutes: durationValue,
scheduledAt,
reason,
});
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: `요청 완료: ${action}`,
};
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: `요청 실패: ${action}`,
};
}
const actionState = profileActions.value[profileName];
const durationMinutes = actionState?.durationMinutes ? Number(actionState.durationMinutes) : undefined;
const durationValue = durationMinutes && durationMinutes > 0 ? durationMinutes : undefined;
const scheduledAt = actionState?.scheduledAt ? new Date(actionState.scheduledAt).toISOString() : undefined;
const reason = actionState?.reason.trim() || undefined;
try {
await adminClient.profiles.requestAction.mutate({
profileName,
action,
durationMinutes: durationValue,
scheduledAt,
reason,
});
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: `요청 완료: ${action}`,
};
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: `요청 실패: ${action}`,
};
}
};
const lookupUser = async () => {
userLoading.value = true;
userError.value = '';
passwordStatus.value = '';
rolesStatus.value = '';
banStatus.value = '';
restrictionStatus.value = '';
profileIconStatus.value = '';
forceDeleteStatus.value = '';
try {
const payload =
userLookupMode.value === 'id'
? { id: userLookupValue.value.trim() }
: userLookupMode.value === 'email'
? { email: userLookupValue.value.trim() }
: { username: userLookupValue.value.trim() };
const result = await adminClient.users.lookup.query(payload);
if (!result) {
userResult.value = null;
userError.value = '사용자를 찾을 수 없습니다.';
return;
userLoading.value = true;
userError.value = '';
passwordStatus.value = '';
rolesStatus.value = '';
banStatus.value = '';
restrictionStatus.value = '';
profileIconStatus.value = '';
forceDeleteStatus.value = '';
try {
const payload =
userLookupMode.value === 'id'
? { id: userLookupValue.value.trim() }
: userLookupMode.value === 'email'
? { email: userLookupValue.value.trim() }
: { username: userLookupValue.value.trim() };
const result = await adminClient.users.lookup.query(payload);
if (!result) {
userResult.value = null;
userError.value = '사용자를 찾을 수 없습니다.';
return;
}
userResult.value = result;
} catch (error) {
userError.value = '조회 실패';
} finally {
userLoading.value = false;
}
userResult.value = result;
} catch (error) {
userError.value = '조회 실패';
} finally {
userLoading.value = false;
}
};
const resetUserPassword = async () => {
if (!userResult.value) {
return;
}
passwordStatus.value = '';
passwordResult.value = '';
try {
const result = await adminClient.users.resetPassword.mutate({
userId: userResult.value.id,
newPassword: passwordInput.value.trim() || undefined,
});
passwordResult.value = result.password;
passwordStatus.value = '초기화 완료';
passwordInput.value = '';
} catch (error) {
passwordStatus.value = '초기화 실패';
}
if (!userResult.value) {
return;
}
passwordStatus.value = '';
passwordResult.value = '';
try {
const result = await adminClient.users.resetPassword.mutate({
userId: userResult.value.id,
newPassword: passwordInput.value.trim() || undefined,
});
passwordResult.value = result.password;
passwordStatus.value = '초기화 완료';
passwordInput.value = '';
} catch (error) {
passwordStatus.value = '초기화 실패';
}
};
const updateUserRoles = async () => {
if (!userResult.value) {
return;
}
const roles = rolesInput.value
.split(',')
.map((role) => role.trim())
.filter(Boolean);
if (!roles.length) {
rolesStatus.value = '역할을 입력하세요.';
return;
}
rolesStatus.value = '';
try {
const result = await adminClient.users.updateRoles.mutate({
userId: userResult.value.id,
roles,
mode: rolesMode.value,
});
userResult.value = { ...userResult.value, roles: result.roles };
rolesStatus.value = '권한 업데이트 완료';
} catch (error) {
rolesStatus.value = '권한 업데이트 실패';
}
if (!userResult.value) {
return;
}
const roles = rolesInput.value
.split(',')
.map((role) => role.trim())
.filter(Boolean);
if (!roles.length) {
rolesStatus.value = '역할을 입력하세요.';
return;
}
rolesStatus.value = '';
try {
const result = await adminClient.users.updateRoles.mutate({
userId: userResult.value.id,
roles,
mode: rolesMode.value,
});
userResult.value = { ...userResult.value, roles: result.roles };
rolesStatus.value = '권한 업데이트 완료';
} catch (error) {
rolesStatus.value = '권한 업데이트 실패';
}
};
const applyBan = async () => {
if (!userResult.value) {
return;
}
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
const patch = {
bannedUntil: until,
notes: banReason.value.trim() || undefined,
};
try {
const result = await adminClient.users.updateSanctions.mutate({
userId: userResult.value.id,
patch,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
banStatus.value = '차단 설정 완료';
} catch (error) {
banStatus.value = '차단 설정 실패';
}
if (!userResult.value) {
return;
}
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
const patch = {
bannedUntil: until,
notes: banReason.value.trim() || undefined,
};
try {
const result = await adminClient.users.updateSanctions.mutate({
userId: userResult.value.id,
patch,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
banStatus.value = '차단 설정 완료';
} catch (error) {
banStatus.value = '차단 설정 실패';
}
};
const clearBan = async () => {
if (!userResult.value) {
return;
}
try {
const result = await adminClient.users.updateSanctions.mutate({
userId: userResult.value.id,
patch: { bannedUntil: null },
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
banStatus.value = '차단 해제 완료';
} catch (error) {
banStatus.value = '차단 해제 실패';
}
if (!userResult.value) {
return;
}
try {
const result = await adminClient.users.updateSanctions.mutate({
userId: userResult.value.id,
patch: { bannedUntil: null },
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
banStatus.value = '차단 해제 완료';
} catch (error) {
banStatus.value = '차단 해제 실패';
}
};
const resetProfileIcon = async () => {
if (!userResult.value) {
return;
}
try {
const result = await adminClient.users.resetProfileIcon.mutate({
userId: userResult.value.id,
});
userResult.value = {
...userResult.value,
sanctions: {
...userResult.value.sanctions,
profileIconResetAt: result.profileIconResetAt,
},
};
profileIconStatus.value = '아이콘 초기화 요청 완료';
} catch (error) {
profileIconStatus.value = '아이콘 초기화 실패';
}
if (!userResult.value) {
return;
}
try {
const result = await adminClient.users.resetProfileIcon.mutate({
userId: userResult.value.id,
});
userResult.value = {
...userResult.value,
sanctions: {
...userResult.value.sanctions,
profileIconResetAt: result.profileIconResetAt,
},
};
profileIconStatus.value = '아이콘 초기화 요청 완료';
} catch (error) {
profileIconStatus.value = '아이콘 초기화 실패';
}
};
const applyRestriction = async () => {
if (!userResult.value) {
return;
}
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.';
return;
}
const features = restrictionFeatures.value
.split(',')
.map((feature) => feature.trim())
.filter(Boolean);
const restriction = {
blockedFeatures: features.length ? features : undefined,
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined,
reason: restrictionReason.value.trim() || undefined,
notes: restrictionNotes.value.trim() || undefined,
};
try {
const result = await adminClient.users.setServerRestriction.mutate({
userId: userResult.value.id,
profile: restrictionProfile.value.trim(),
restriction,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
restrictionStatus.value = '서버 제재 적용 완료';
} catch (error) {
restrictionStatus.value = '서버 제재 적용 실패';
}
if (!userResult.value) {
return;
}
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.';
return;
}
const features = restrictionFeatures.value
.split(',')
.map((feature) => feature.trim())
.filter(Boolean);
const restriction = {
blockedFeatures: features.length ? features : undefined,
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined,
reason: restrictionReason.value.trim() || undefined,
notes: restrictionNotes.value.trim() || undefined,
};
try {
const result = await adminClient.users.setServerRestriction.mutate({
userId: userResult.value.id,
profile: restrictionProfile.value.trim(),
restriction,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
restrictionStatus.value = '서버 제재 적용 완료';
} catch (error) {
restrictionStatus.value = '서버 제재 적용 실패';
}
};
const clearRestriction = async () => {
if (!userResult.value) {
return;
}
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.';
return;
}
try {
const result = await adminClient.users.setServerRestriction.mutate({
userId: userResult.value.id,
profile: restrictionProfile.value.trim(),
restriction: null,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
restrictionStatus.value = '서버 제재 해제 완료';
} catch (error) {
restrictionStatus.value = '서버 제재 해제 실패';
}
if (!userResult.value) {
return;
}
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '서버 프로필명을 입력하세요.';
return;
}
try {
const result = await adminClient.users.setServerRestriction.mutate({
userId: userResult.value.id,
profile: restrictionProfile.value.trim(),
restriction: null,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
restrictionStatus.value = '서버 제재 해제 완료';
} catch (error) {
restrictionStatus.value = '서버 제재 해제 실패';
}
};
const forceDeleteUser = async () => {
if (!userResult.value) {
return;
}
if (typeof window !== 'undefined') {
const confirmed = window.confirm('정말로 강제 탈퇴 처리하시겠습니까?');
if (!confirmed) {
return;
if (!userResult.value) {
return;
}
if (typeof window !== 'undefined') {
const confirmed = window.confirm('정말로 강제 탈퇴 처리하시겠습니까?');
if (!confirmed) {
return;
}
}
try {
await adminClient.users.forceDelete.mutate({ userId: userResult.value.id });
userResult.value = null;
forceDeleteStatus.value = '강제 탈퇴 완료';
} catch (error) {
forceDeleteStatus.value = '강제 탈퇴 실패';
}
}
try {
await adminClient.users.forceDelete.mutate({ userId: userResult.value.id });
userResult.value = null;
forceDeleteStatus.value = '강제 탈퇴 완료';
} catch (error) {
forceDeleteStatus.value = '강제 탈퇴 실패';
}
};
onMounted(() => {
void loadNotice();
void loadProfiles();
void loadNotice();
void loadProfiles();
});
</script>
<template>
<DefaultLayout>
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
<div class="space-y-2">
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
<p class="text-sm text-zinc-400">
유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.
</p>
</div>
<DefaultLayout>
<div class="max-w-6xl mx-auto px-4 py-10 space-y-10">
<div class="space-y-2">
<h2 class="text-3xl font-bold text-white">관리자 콘솔</h2>
<p class="text-sm text-zinc-400">유저 관리와 서버 운영 제어를 위한 관리자 전용 대시보드입니다.</p>
</div>
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
<section class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
</div>
<div class="flex flex-col md:flex-row gap-3">
<input
v-model="sessionToken"
type="password"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="세션 토큰 입력"
/>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
@click="saveSessionToken"
>
저장
</button>
</div>
</section>
<div class="grid lg:grid-cols-2 gap-8">
<section class="space-y-6">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h3 class="text-lg font-semibold">유저 관리</h3>
<form class="space-y-3" @submit.prevent="lookupUser">
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="userLookupMode"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
>
<option value="username">계정명</option>
<option value="email">이메일</option>
<option value="id">UUID</option>
</select>
<input
v-model="userLookupValue"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="검색 값 입력"
/>
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
:disabled="userLoading"
>
조회
</button>
</div>
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
</form>
<div v-if="userResult" class="bg-zinc-950 border border-zinc-800 rounded p-4 space-y-2">
<div class="flex justify-between items-center">
<div class="text-sm font-semibold">{{ userResult.username }}</div>
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
</div>
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
<div class="text-xs text-zinc-400">권한: {{ userResult.roles.join(', ') || '-' }}</div>
<div class="text-xs text-zinc-500">
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
</div>
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
</pre
>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
<div class="flex flex-col md:flex-row gap-2">
<input
v-model="passwordInput"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="직접 지정 시 입력"
:disabled="!hasUser"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="resetUserPassword"
>
초기화
</button>
</div>
<div class="text-xs text-zinc-400">임시 비밀번호: {{ passwordResult || '-' }}</div>
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">특수 권한 부여</h4>
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="rolesMode"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option value="grant">추가</option>
<option value="revoke">제거</option>
<option value="set">덮어쓰기</option>
</select>
<input
v-model="rolesInput"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="admin, moderator 등"
:disabled="!hasUser"
/>
<button
class="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateUserRoles"
>
적용
</button>
</div>
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">유저 차단</h4>
<div class="flex flex-col gap-2">
<input
v-model="banUntil"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<input
v-model="banReason"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유"
:disabled="!hasUser"
/>
<div class="flex gap-2">
<button
class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="applyBan"
>
차단 설정
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="clearBan"
>
차단 해제
</button>
</div>
</div>
<div class="text-xs text-zinc-500">{{ banStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
<div class="grid gap-2">
<input
v-model="restrictionProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="profile:scenario"
:disabled="!hasUser"
/>
<input
v-model="restrictionFeatures"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="login, message 등"
:disabled="!hasUser"
/>
<input
v-model="restrictionUntil"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<input
v-model="restrictionReason"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유"
:disabled="!hasUser"
/>
<input
v-model="restrictionNotes"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="메모"
:disabled="!hasUser"
/>
<div class="flex gap-2">
<button
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="applyRestriction"
>
제재 적용
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="clearRestriction"
>
제재 해제
</button>
</div>
</div>
<div class="text-xs text-zinc-500">{{ restrictionStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">프로필 아이콘 초기화</h4>
<button
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="resetProfileIcon"
>
아이콘 초기화 요청
</button>
<div class="text-xs text-zinc-500">{{ profileIconStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold text-red-400">강제 탈퇴</h4>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="forceDeleteUser"
>
강제 탈퇴 처리
</button>
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
</div>
</section>
<section class="space-y-6">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">서버 공지</h3>
<span class="text-xs text-zinc-500">{{ noticeStatus }}</span>
</div>
<textarea
v-model="noticeDraft"
class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-sm text-white min-h-[140px]"
placeholder="로비 공지 입력"
/>
<div class="flex gap-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
:disabled="noticeLoading"
@click="saveNotice"
>
공지 저장
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="noticeLoading"
@click="loadNotice"
>
다시 불러오기
</button>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">서버별 관리</h3>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
</div>
<div
v-for="profile in profiles"
:key="profile.profileName"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div class="text-base font-semibold">
{{ profile.profileName }} ({{ profile.profile }})
</div>
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }}
</div>
</div>
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
<div class="grid md:grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-xs text-zinc-400">표시명</label>
<input
v-model="profileEdits[profile.profileName].korName"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">색상</label>
<input
v-model="profileEdits[profile.profileName].color"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">인게임 공지</label>
<textarea
v-model="profileEdits[profile.profileName].inGameNotice"
class="w-full bg-zinc-950 border border-zinc-700 rounded p-2 text-sm text-white min-h-[80px]"
/>
<label class="text-xs text-zinc-400">프로필 이미지</label>
<input
v-model="profileEdits[profile.profileName].profileImageUrl"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
@click="updateProfileMeta(profile.profileName)"
>
메타 저장
</button>
</div>
<div class="space-y-2">
<label class="text-xs text-zinc-400">특수 동작 메모</label>
<input
v-model="profileActions[profile.profileName].reason"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유 / 메모"
/>
<label class="text-xs text-zinc-400">가속/연기 ()</label>
<input
v-model="profileActions[profile.profileName].durationMinutes"
type="number"
min="1"
max="1440"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">리셋 예약</label>
<input
v-model="profileActions[profile.profileName].scheduledAt"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<div class="grid grid-cols-2 gap-2 pt-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESUME')"
>
재개
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'PAUSE')"
>
일시정지
</button>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'STOP')"
>
중지
</button>
<button
class="bg-orange-600 hover:bg-orange-500 text-black font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'ACCELERATE')"
>
가속
</button>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'DELAY')"
>
연기
</button>
<button
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESET_NOW')"
>
즉시 리셋
</button>
<button
class="bg-purple-800 hover:bg-purple-700 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESET_SCHEDULED')"
>
리셋 예약
</button>
<button
class="bg-teal-700 hover:bg-teal-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'OPEN_SURVEY')"
>
설문 오픈
</button>
<button
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
>
서버 폐쇄
</button>
</div>
<div class="text-xs text-zinc-500">
{{ profileActionStatus[profile.profileName] }}
</div>
</div>
</div>
</div>
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
{{ profileActionStatus.global }}
</div>
</div>
</section>
</div>
</div>
<div class="flex flex-col md:flex-row gap-3">
<input
v-model="sessionToken"
type="password"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="세션 토큰 입력"
/>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
@click="saveSessionToken"
>
저장
</button>
</div>
</section>
<div class="grid lg:grid-cols-2 gap-8">
<section class="space-y-6">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h3 class="text-lg font-semibold">유저 관리</h3>
<form class="space-y-3" @submit.prevent="lookupUser">
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="userLookupMode"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
>
<option value="username">계정명</option>
<option value="email">이메일</option>
<option value="id">UUID</option>
</select>
<input
v-model="userLookupValue"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="검색 값 입력"
/>
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
:disabled="userLoading"
>
조회
</button>
</div>
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
</form>
<div v-if="userResult" class="bg-zinc-950 border border-zinc-800 rounded p-4 space-y-2">
<div class="flex justify-between items-center">
<div class="text-sm font-semibold">{{ userResult.username }}</div>
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
</div>
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
<div class="text-xs text-zinc-400">권한: {{ userResult.roles.join(', ') || '-' }}</div>
<div class="text-xs text-zinc-500">OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}</div>
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap">
{{ JSON.stringify(userResult.sanctions, null, 2) }}
</pre>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
<div class="flex flex-col md:flex-row gap-2">
<input
v-model="passwordInput"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="직접 지정 시 입력"
:disabled="!hasUser"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="resetUserPassword"
>
초기화
</button>
</div>
<div class="text-xs text-zinc-400">임시 비밀번호: {{ passwordResult || '-' }}</div>
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">특수 권한 부여</h4>
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="rolesMode"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option value="grant">추가</option>
<option value="revoke">제거</option>
<option value="set">덮어쓰기</option>
</select>
<input
v-model="rolesInput"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="admin, moderator 등"
:disabled="!hasUser"
/>
<button
class="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateUserRoles"
>
적용
</button>
</div>
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">유저 차단</h4>
<div class="flex flex-col gap-2">
<input
v-model="banUntil"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<input
v-model="banReason"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유"
:disabled="!hasUser"
/>
<div class="flex gap-2">
<button
class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="applyBan"
>
차단 설정
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="clearBan"
>
차단 해제
</button>
</div>
</div>
<div class="text-xs text-zinc-500">{{ banStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">서버별 기능 제재</h4>
<div class="grid gap-2">
<input
v-model="restrictionProfile"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="profile:scenario"
:disabled="!hasUser"
/>
<input
v-model="restrictionFeatures"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="login, message 등"
:disabled="!hasUser"
/>
<input
v-model="restrictionUntil"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<input
v-model="restrictionReason"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유"
:disabled="!hasUser"
/>
<input
v-model="restrictionNotes"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="메모"
:disabled="!hasUser"
/>
<div class="flex gap-2">
<button
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="applyRestriction"
>
제재 적용
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="clearRestriction"
>
제재 해제
</button>
</div>
</div>
<div class="text-xs text-zinc-500">{{ restrictionStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">프로필 아이콘 초기화</h4>
<button
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="resetProfileIcon"
>
아이콘 초기화 요청
</button>
<div class="text-xs text-zinc-500">{{ profileIconStatus }}</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold text-red-400">강제 탈퇴</h4>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="forceDeleteUser"
>
강제 탈퇴 처리
</button>
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
</div>
</section>
<section class="space-y-6">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">서버 공지</h3>
<span class="text-xs text-zinc-500">{{ noticeStatus }}</span>
</div>
<textarea
v-model="noticeDraft"
class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-sm text-white min-h-[140px]"
placeholder="로비 공지 입력"
/>
<div class="flex gap-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
:disabled="noticeLoading"
@click="saveNotice"
>
공지 저장
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="noticeLoading"
@click="loadNotice"
>
다시 불러오기
</button>
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">서버별 관리</h3>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
</div>
<div
v-for="profile in profiles"
:key="profile.profileName"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div class="text-base font-semibold">
{{ profile.profileName }} ({{ profile.profile }})
</div>
<div class="text-xs text-zinc-500">시나리오: {{ profile.scenario }}</div>
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }}
</div>
</div>
<div class="text-xs text-zinc-400">
빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}
</div>
<div class="grid md:grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-xs text-zinc-400">표시명</label>
<input
v-model="profileEdits[profile.profileName].korName"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">색상</label>
<input
v-model="profileEdits[profile.profileName].color"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">인게임 공지</label>
<textarea
v-model="profileEdits[profile.profileName].inGameNotice"
class="w-full bg-zinc-950 border border-zinc-700 rounded p-2 text-sm text-white min-h-[80px]"
/>
<label class="text-xs text-zinc-400">프로필 이미지</label>
<input
v-model="profileEdits[profile.profileName].profileImageUrl"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
@click="updateProfileMeta(profile.profileName)"
>
메타 저장
</button>
</div>
<div class="space-y-2">
<label class="text-xs text-zinc-400">특수 동작 메모</label>
<input
v-model="profileActions[profile.profileName].reason"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유 / 메모"
/>
<label class="text-xs text-zinc-400">가속/연기 ()</label>
<input
v-model="profileActions[profile.profileName].durationMinutes"
type="number"
min="1"
max="1440"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">리셋 예약</label>
<input
v-model="profileActions[profile.profileName].scheduledAt"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<div class="grid grid-cols-2 gap-2 pt-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESUME')"
>
재개
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'PAUSE')"
>
일시정지
</button>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'STOP')"
>
중지
</button>
<button
class="bg-orange-600 hover:bg-orange-500 text-black font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'ACCELERATE')"
>
가속
</button>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'DELAY')"
>
연기
</button>
<button
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESET_NOW')"
>
즉시 리셋
</button>
<button
class="bg-purple-800 hover:bg-purple-700 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'RESET_SCHEDULED')"
>
리셋 예약
</button>
<button
class="bg-teal-700 hover:bg-teal-600 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'OPEN_SURVEY')"
>
설문 오픈
</button>
<button
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded col-span-2"
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
>
서버 폐쇄
</button>
</div>
<div class="text-xs text-zinc-500">
{{ profileActionStatus[profile.profileName] }}
</div>
</div>
</div>
</div>
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
{{ profileActionStatus.global }}
</div>
</div>
</section>
</div>
</div>
</DefaultLayout>
</DefaultLayout>
</template>
+100 -97
View File
@@ -9,117 +9,120 @@ const username = ref('');
const password = ref('');
onMounted(async () => {
try {
const me = await trpc.me.query();
if (me) {
router.push('/lobby');
try {
const me = await trpc.me.query();
if (me) {
router.push('/lobby');
}
} catch (e) {
// Not logged in or error
}
} catch (e) {
// Not logged in or error
}
});
const handleLogin = async () => {
try {
// TODO: Implement login mutation
// const result = await trpc.auth.login.mutation({ username: username.value, password: password.value });
// if (result.success) router.push('/lobby');
console.log('Login attempt:', username.value);
} catch (e) {
alert('로그인 실패');
}
try {
// TODO: Implement login mutation
// const result = await trpc.auth.login.mutation({ username: username.value, password: password.value });
// if (result.success) router.push('/lobby');
console.log('Login attempt:', username.value);
} catch (e) {
alert('로그인 실패');
}
};
const handleJoin = () => {
console.log('Join attempt');
console.log('Join attempt');
};
</script>
<template>
<DefaultLayout>
<div class="max-w-4xl mx-auto py-12 px-4 flex flex-col items-center space-y-12">
<!-- Logo / Title -->
<div class="text-center">
<h2 class="text-4xl font-serif font-bold text-white tracking-widest">삼국지 모의전투 HiDCHe</h2>
</div>
<DefaultLayout>
<div class="max-w-4xl mx-auto py-12 px-4 flex flex-col items-center space-y-12">
<!-- Logo / Title -->
<div class="text-center">
<h2 class="text-4xl font-serif font-bold text-white tracking-widest">삼국지 모의전투 HiDCHe</h2>
</div>
<!-- Login Box -->
<div class="w-full max-w-md bg-zinc-800 border border-zinc-700 rounded shadow-2xl overflow-hidden">
<div class="bg-zinc-700 px-6 py-2 text-center font-bold text-white border-b border-zinc-600">
로그인
</div>
<div class="p-6 space-y-4">
<div class="flex items-center space-x-4">
<label class="w-20 text-sm font-medium">계정명</label>
<input
v-model="username"
type="text"
class="flex-grow bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
placeholder="계정명"
/>
</div>
<div class="flex items-center space-x-4">
<label class="w-20 text-sm font-medium">비밀번호</label>
<input
v-model="password"
type="password"
class="flex-grow bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
placeholder="비밀번호"
/>
</div>
<div class="flex space-x-2 pt-2">
<button
@click="handleJoin"
class="flex-1 bg-yellow-600 hover:bg-yellow-500 text-black font-bold py-2 rounded transition-colors flex items-center justify-center space-x-2"
>
<span>가입 & 로그인</span>
</button>
<button
@click="handleLogin"
class="flex-[2] bg-blue-700 hover:bg-blue-600 text-white font-bold py-2 rounded transition-colors"
>
로그인
</button>
</div>
</div>
</div>
<!-- Login Box -->
<div class="w-full max-w-md bg-zinc-800 border border-zinc-700 rounded shadow-2xl overflow-hidden">
<div class="bg-zinc-700 px-6 py-2 text-center font-bold text-white border-b border-zinc-600">
로그인
</div>
<div class="p-6 space-y-4">
<div class="flex items-center space-x-4">
<label class="w-20 text-sm font-medium">계정명</label>
<input
v-model="username"
type="text"
class="flex-grow bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
placeholder="계정명"
/>
</div>
<div class="flex items-center space-x-4">
<label class="w-20 text-sm font-medium">비밀번호</label>
<input
v-model="password"
type="password"
class="flex-grow bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-white focus:outline-none focus:border-blue-500"
placeholder="비밀번호"
/>
</div>
<div class="flex space-x-2 pt-2">
<button
@click="handleJoin"
class="flex-1 bg-yellow-600 hover:bg-yellow-500 text-black font-bold py-2 rounded transition-colors flex items-center justify-center space-x-2"
>
<span>가입 & 로그인</span>
</button>
<button
@click="handleLogin"
class="flex-[2] bg-blue-700 hover:bg-blue-600 text-white font-bold py-2 rounded transition-colors"
>
로그인
</button>
</div>
</div>
</div>
<!-- Server Status Placeholder -->
<div class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden shadow-xl">
<div class="bg-zinc-800 px-4 py-2 border-b border-zinc-700 flex justify-between items-center">
<span class="font-bold text-sm"> 현황</span>
<span class="text-xs text-zinc-400">西紀 197 7 </span>
<!-- Server Status Placeholder -->
<div class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden shadow-xl">
<div class="bg-zinc-800 px-4 py-2 border-b border-zinc-700 flex justify-between items-center">
<span class="font-bold text-sm"> 현황</span>
<span class="text-xs text-zinc-400">西紀 197 7 </span>
</div>
<div class="aspect-video bg-zinc-950 relative flex items-center justify-center">
<!-- Map Placeholder -->
<div class="text-zinc-700 text-lg italic">지도 이미지 현황 데이터 영역</div>
<!-- Example of a city dot if we wanted to mock it -->
<div class="absolute bottom-4 right-4 text-[10px] text-blue-400">도시명 표기 끄기</div>
</div>
<div class="p-4 bg-black text-xs space-y-1 font-mono">
<div class="flex items-start space-x-2">
<span class="text-blue-400"></span>
<span
>197 7: [대회] 황제 수장의 명으로 전력전 대회가 개최됩니다! 천하의 영웅들을 모집하고
있습니다!</span
>
</div>
<div class="flex items-start space-x-2">
<span class="text-cyan-400"></span>
<span>197 7: [재난] 탐라 호토에 메뚜기 떼가 발생하여 도시가 황폐해지고 있습니다.</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-green-400"></span>
<span>197 7: [자금] 가을이 되어 봉록에 따라 군량이 지급됩니다.</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-red-400"></span>
<span>197 4: [재난] 남피, 무안에 홍수로 인해 피해가 급증하고 있습니다.</span>
</div>
<!-- ... more logs ... -->
<div class="text-zinc-600 pt-2 italic text-center">최근 진행 상황 로그 (Placeholder)</div>
</div>
</div>
</div>
<div class="aspect-video bg-zinc-950 relative flex items-center justify-center">
<!-- Map Placeholder -->
<div class="text-zinc-700 text-lg italic">지도 이미지 현황 데이터 영역</div>
<!-- Example of a city dot if we wanted to mock it -->
<div class="absolute bottom-4 right-4 text-[10px] text-blue-400">도시명 표기 끄기</div>
</div>
<div class="p-4 bg-black text-xs space-y-1 font-mono">
<div class="flex items-start space-x-2">
<span class="text-blue-400"></span>
<span>197 7: [대회] 황제 수장의 명으로 전력전 대회가 개최됩니다! 천하의 영웅들을 모집하고 있습니다!</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-cyan-400"></span>
<span>197 7: [재난] 탐라 호토에 메뚜기 떼가 발생하여 도시가 황폐해지고 있습니다.</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-green-400"></span>
<span>197 7: [자금] 가을이 되어 봉록에 따라 군량이 지급됩니다.</span>
</div>
<div class="flex items-start space-x-2">
<span class="text-red-400"></span>
<span>197 4: [재난] 남피, 무안에 홍수로 인해 피해가 급증하고 있습니다.</span>
</div>
<!-- ... more logs ... -->
<div class="text-zinc-600 pt-2 italic text-center">최근 진행 상황 로그 (Placeholder)</div>
</div>
</div>
</div>
</DefaultLayout>
</DefaultLayout>
</template>
<style scoped>
+211 -146
View File
@@ -12,167 +12,232 @@ const profiles = ref<any[]>([]);
const profileDetails = ref<Record<string, any>>({});
onMounted(async () => {
try {
me.value = await trpc.me.query();
if (!me.value) {
router.push('/');
return;
}
notice.value = await trpc.lobby.notice.query();
profiles.value = await trpc.lobby.profiles.query();
// Fetch details for each profile
for (const profile of profiles.value) {
if (profile.status === 'RUNNING' || profile.status === 'PREOPEN') {
try {
const gameTrpc = createGameTrpc(profile.apiPort);
const info = await gameTrpc.lobby.info.query();
profileDetails.value[profile.profileName] = info;
} catch (e) {
console.error(`Failed to fetch info for ${profile.profileName}`, e);
try {
me.value = await trpc.me.query();
if (!me.value) {
router.push('/');
return;
}
}
notice.value = await trpc.lobby.notice.query();
profiles.value = await trpc.lobby.profiles.query();
// Fetch details for each profile
for (const profile of profiles.value) {
if (profile.status === 'RUNNING' || profile.status === 'PREOPEN') {
try {
const gameTrpc = createGameTrpc(profile.apiPort);
const info = await gameTrpc.lobby.info.query();
profileDetails.value[profile.profileName] = info;
} catch (e) {
console.error(`Failed to fetch info for ${profile.profileName}`, e);
}
}
}
} catch (e) {
console.error('Failed to load lobby', e);
}
} catch (e) {
console.error('Failed to load lobby', e);
}
});
const handleLogout = async () => {
// TODO: Implement logout mutation in gateway-api
// await trpc.auth.logout.mutation();
router.push('/');
// TODO: Implement logout mutation in gateway-api
// await trpc.auth.logout.mutation();
router.push('/');
};
</script>
<template>
<DefaultLayout>
<div class="max-w-5xl mx-auto py-8 px-4 space-y-8">
<!-- Notice -->
<div v-if="notice" class="text-center">
<span class="text-orange-500 text-3xl font-bold" v-html="notice"></span>
</div>
<DefaultLayout>
<div class="max-w-5xl mx-auto py-8 px-4 space-y-8">
<!-- Notice -->
<div v-if="notice" class="text-center">
<span class="text-orange-500 text-3xl font-bold" v-html="notice"></span>
</div>
<!-- Server List Table -->
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
<div class="bg-zinc-800 px-6 py-3 text-center font-bold text-white border-b border-zinc-700 text-xl tracking-widest">
</div>
<table class="w-full text-sm text-left">
<thead class="bg-zinc-800 text-zinc-400 uppercase text-xs">
<tr>
<th class="px-4 py-3 border-b border-zinc-700 w-24 text-center"> </th>
<th class="px-4 py-3 border-b border-zinc-700"> </th>
<th class="px-4 py-3 border-b border-zinc-700 w-48 text-center" colspan="2"> </th>
<th class="px-4 py-3 border-b border-zinc-700 w-32 text-center"> </th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr v-for="profile in profiles" :key="profile.profileName" class="hover:bg-zinc-800/50 transition-colors">
<!-- Server Name -->
<td class="px-4 py-4 text-center border-r border-zinc-800">
<div
:style="{ color: profile.color }"
class="text-lg font-bold cursor-help"
:title="profileDetails[profile.profileName] ? `시작일: ${profileDetails[profile.profileName].starttime}` : ''"
<!-- Server List Table -->
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
<div
class="bg-zinc-800 px-6 py-3 text-center font-bold text-white border-b border-zinc-700 text-xl tracking-widest"
>
{{ profile.korName }}
</div>
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-500 mt-1">
&lt;{{ profileDetails[profile.profileName].nationCnt }} 경쟁중&gt;
</div>
</td>
<table class="w-full text-sm text-left">
<thead class="bg-zinc-800 text-zinc-400 uppercase text-xs">
<tr>
<th class="px-4 py-3 border-b border-zinc-700 w-24 text-center"> </th>
<th class="px-4 py-3 border-b border-zinc-700"> </th>
<th class="px-4 py-3 border-b border-zinc-700 w-48 text-center" colspan="2"> </th>
<th class="px-4 py-3 border-b border-zinc-700 w-32 text-center"> </th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="profile in profiles"
:key="profile.profileName"
class="hover:bg-zinc-800/50 transition-colors"
>
<!-- Server Name -->
<td class="px-4 py-4 text-center border-r border-zinc-800">
<div
:style="{ color: profile.color }"
class="text-lg font-bold cursor-help"
:title="
profileDetails[profile.profileName]
? `시작일: ${profileDetails[profile.profileName].starttime}`
: ''
"
>
{{ profile.korName }}
</div>
<div v-if="profileDetails[profile.profileName]" class="text-xs text-zinc-500 mt-1">
&lt;{{ profileDetails[profile.profileName].nationCnt }} 경쟁중&gt;
</div>
</td>
<!-- Server Info -->
<td class="px-4 py-4 border-r border-zinc-800">
<template v-if="profileDetails[profile.profileName]">
<div class="space-y-1">
<div>
서기 {{ profileDetails[profile.profileName].year }} {{ profileDetails[profile.profileName].month }}
(<span class="text-orange-400">{{ profile.scenario }}</span>)
<!-- Server Info -->
<td class="px-4 py-4 border-r border-zinc-800">
<template v-if="profileDetails[profile.profileName]">
<div class="space-y-1">
<div>
서기 {{ profileDetails[profile.profileName].year }}
{{ profileDetails[profile.profileName].month }} (<span
class="text-orange-400"
>{{ profile.scenario }}</span
>)
</div>
<div class="text-zinc-400">
유저 : {{ profileDetails[profile.profileName].userCnt }} /
{{ profileDetails[profile.profileName].maxUserCnt }}
<span class="text-cyan-400 ml-2"
>NPC : {{ profileDetails[profile.profileName].npcCnt }}</span
>
<span class="text-green-400 ml-2"
>({{ profileDetails[profile.profileName].turnTerm }} 서버)</span
>
</div>
<div class="text-xs text-zinc-500">
(상성 설정:{{ profileDetails[profile.profileName].fictionMode }}), (기타
설정:{{ profileDetails[profile.profileName].otherTextInfo }})
</div>
</div>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<div class="text-center text-zinc-600 py-2">- -</div>
</template>
<template v-else>
<div class="text-center text-zinc-500 py-2">정보를 불러오는 ...</div>
</template>
</td>
<!-- Character Info -->
<td class="px-2 py-4 w-16 border-r border-zinc-800">
<div
v-if="profileDetails[profile.profileName]?.myGeneral"
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
>
<img
:src="profileDetails[profile.profileName].myGeneral.picture"
class="w-full h-full object-cover"
/>
</div>
</td>
<td class="px-4 py-4 border-r border-zinc-800 text-center">
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
{{ profileDetails[profile.profileName].myGeneral.name }}
</div>
<div v-else class="text-zinc-600">- -</div>
</td>
<!-- Action -->
<td class="px-4 py-4 text-center">
<template v-if="profileDetails[profile.profileName]">
<button
v-if="profileDetails[profile.profileName].myGeneral"
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
>
입장
</button>
<button
v-else
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
>
장수생성
</button>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<span class="text-zinc-700">-</span>
</template>
</td>
</tr>
</tbody>
</table>
<!-- Footer Info -->
<div class="bg-zinc-800/50 p-4 text-xs text-zinc-500 space-y-2 border-t border-zinc-800">
<p class="text-red-500 font-bold">
1명이 2 이상의 계정을 사용하거나 유저의 턴을 대신 입력하는 것이 적발될 경우 차단
있습니다.
</p>
<p>계정은 한번 등록으로 계속 사용합니다. 서버 리셋시 캐릭터만 새로 생성하면 됩니다.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-1 mt-2">
<p>
<span class="text-zinc-300 font-bold">체섭</span> : 메인서버입니다. 천하통일에 도전하여
왕조일람과 명예의전당에 올라봅시다! (주로 1=60)
</p>
<p>
<span class="text-zinc-300 font-bold">퀘섭</span> : 마이너 서버 그룹1. 비교적 느린 시간으로
운영됩니다.
</p>
<p>
<span class="text-zinc-300 font-bold">풰섭</span> : 마이너 서버 그룹1. 비교적 느린 시간으로
운영됩니다.
</p>
<p>
<span class="text-zinc-300 font-bold">퉤섭</span> : 마이너 서버 그룹2. 비교적 빠른 시간으로
운영됩니다.
</p>
<p>
<span class="text-zinc-300 font-bold">냐섭</span> : 마이너 서버 그룹3. 독특한 컨셉 위주로
운영됩니다.
</p>
<p>
<span class="text-zinc-300 font-bold">퍄섭</span> : 마이너 서버 그룹3. 독특한 컨셉 위주로
운영됩니다.
</p>
<p>
<span class="text-zinc-300 font-bold">훼섭</span> : 운영자 테스트 서버입니다. 기습적으로
열리고, 닫힐 있습니다.
</p>
</div>
<div class="text-zinc-400">
유저 : {{ profileDetails[profile.profileName].userCnt }} / {{ profileDetails[profile.profileName].maxUserCnt }}
<span class="text-cyan-400 ml-2">NPC : {{ profileDetails[profile.profileName].npcCnt }}</span>
<span class="text-green-400 ml-2">({{ profileDetails[profile.profileName].turnTerm }} 서버)</span>
</div>
<div class="text-xs text-zinc-500">
(상성 설정:{{ profileDetails[profile.profileName].fictionMode }}), (기타 설정:{{ profileDetails[profile.profileName].otherTextInfo }})
</div>
</div>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<div class="text-center text-zinc-600 py-2">- -</div>
</template>
<template v-else>
<div class="text-center text-zinc-500 py-2">정보를 불러오는 ...</div>
</template>
</td>
<!-- Character Info -->
<td class="px-2 py-4 w-16 border-r border-zinc-800">
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700">
<img :src="profileDetails[profile.profileName].myGeneral.picture" class="w-full h-full object-cover" />
</div>
</td>
<td class="px-4 py-4 border-r border-zinc-800 text-center">
<div v-if="profileDetails[profile.profileName]?.myGeneral" class="font-medium">
{{ profileDetails[profile.profileName].myGeneral.name }}
</div>
<!-- Account Management -->
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
<div
class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest"
>
</div>
<div v-else class="text-zinc-600">- -</div>
</td>
<!-- Action -->
<td class="px-4 py-4 text-center">
<template v-if="profileDetails[profile.profileName]">
<button v-if="profileDetails[profile.profileName].myGeneral" class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors">
입장
</button>
<button v-else class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors">
장수생성
</button>
</template>
<template v-else-if="profile.status === 'STOPPED'">
<span class="text-zinc-700">-</span>
</template>
</td>
</tr>
</tbody>
</table>
<!-- Footer Info -->
<div class="bg-zinc-800/50 p-4 text-xs text-zinc-500 space-y-2 border-t border-zinc-800">
<p class="text-red-500 font-bold"> 1명이 2 이상의 계정을 사용하거나 유저의 턴을 대신 입력하는 것이 적발될 경우 차단 있습니다.</p>
<p>계정은 한번 등록으로 계속 사용합니다. 서버 리셋시 캐릭터만 새로 생성하면 됩니다.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-1 mt-2">
<p><span class="text-zinc-300 font-bold">체섭</span> : 메인서버입니다. 천하통일에 도전하여 왕조일람과 명예의전당에 올라봅시다! (주로 1=60)</p>
<p><span class="text-zinc-300 font-bold">퀘섭</span> : 마이너 서버 그룹1. 비교적 느린 시간으로 운영됩니다.</p>
<p><span class="text-zinc-300 font-bold">풰섭</span> : 마이너 서버 그룹1. 비교적 느린 시간으로 운영됩니다.</p>
<p><span class="text-zinc-300 font-bold">퉤섭</span> : 마이너 서버 그룹2. 비교적 빠른 시간으로 운영됩니다.</p>
<p><span class="text-zinc-300 font-bold">냐섭</span> : 마이너 서버 그룹3. 독특한 컨셉 위주로 운영됩니다.</p>
<p><span class="text-zinc-300 font-bold">퍄섭</span> : 마이너 서버 그룹3. 독특한 컨셉 위주로 운영됩니다.</p>
<p><span class="text-zinc-300 font-bold">훼섭</span> : 운영자 테스트 서버입니다. 기습적으로 열리고, 닫힐 있습니다.</p>
</div>
<div class="p-6 flex justify-center space-x-4">
<button
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
비밀번호 & 전콘 & 탈퇴
</button>
<button
@click="handleLogout"
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
</button>
<button
v-if="me?.roles?.includes('admin')"
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
관리자 페이지
</button>
</div>
</div>
</div>
</div>
<!-- Account Management -->
<div class="bg-zinc-900 border border-zinc-800 rounded shadow-xl overflow-hidden">
<div class="bg-zinc-800 px-6 py-2 text-center font-bold text-white border-b border-zinc-700 tracking-widest">
</div>
<div class="p-6 flex justify-center space-x-4">
<button class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors">
비밀번호 & 전콘 & 탈퇴
</button>
<button @click="handleLogout" class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors">
</button>
<button v-if="me?.roles?.includes('admin')" class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors">
관리자 페이지
</button>
</div>
</div>
</div>
</DefaultLayout>
</DefaultLayout>
</template>
+46 -46
View File
@@ -1,51 +1,51 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@sammo-ts/common": ["../../packages/common/src/index.ts"],
"@sammo-ts/common/*": ["../../packages/common/src/*"],
"@sammo-ts/infra": ["../../packages/infra/src/index.ts"],
"@sammo-ts/infra/*": ["../../packages/infra/src/*"],
"@sammo-ts/logic": ["../../packages/logic/src/index.ts"],
"@sammo-ts/logic/*": ["../../packages/logic/src/*"],
"@sammo-ts/game-engine": ["../../app/game-engine/src/index.ts"],
"@sammo-ts/game-engine/*": ["../../app/game-engine/src/*"],
"@sammo-ts/game-api": ["../../app/game-api/src/index.ts"],
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"],
"@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"],
"@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "../../packages/common" },
{ "path": "../../packages/infra" },
{ "path": "../../packages/logic" },
{ "path": "../../app/game-engine" },
{ "path": "../../app/game-api" },
{ "path": "../../app/gateway-api" }
]
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@sammo-ts/common": ["../../packages/common/src/index.ts"],
"@sammo-ts/common/*": ["../../packages/common/src/*"],
"@sammo-ts/infra": ["../../packages/infra/src/index.ts"],
"@sammo-ts/infra/*": ["../../packages/infra/src/*"],
"@sammo-ts/logic": ["../../packages/logic/src/index.ts"],
"@sammo-ts/logic/*": ["../../packages/logic/src/*"],
"@sammo-ts/game-engine": ["../../app/game-engine/src/index.ts"],
"@sammo-ts/game-engine/*": ["../../app/game-engine/src/*"],
"@sammo-ts/game-api": ["../../app/game-api/src/index.ts"],
"@sammo-ts/game-api/*": ["../../app/game-api/src/*"],
"@sammo-ts/gateway-api": ["../../app/gateway-api/src/index.ts"],
"@sammo-ts/gateway-api/*": ["../../app/gateway-api/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "test/**/*.ts", "*.ts"],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "../../packages/common" },
{ "path": "../../packages/infra" },
{ "path": "../../packages/logic" },
{ "path": "../../app/game-engine" },
{ "path": "../../app/game-api" },
{ "path": "../../app/gateway-api" }
]
}
+9 -9
View File
@@ -1,11 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+1 -1
View File
@@ -1,2 +1,2 @@
declare const _default: import("vite").UserConfig;
declare const _default: import('vite').UserConfig;
export default _default;
+1 -4
View File
@@ -4,10 +4,7 @@ import tailwindcss from '@tailwindcss/vite';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+8 -11
View File
@@ -5,16 +5,13 @@ import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
},
},
server: {
port: 3000,
},
});