[UI 개선] 서브메뉴 및 탭 바 컴포넌트 추가, AppHeader 및 기본 레이아웃 수정, API 호출 로직 개선

This commit is contained in:
2025-09-24 16:25:30 +09:00
parent f83782813d
commit f9dde4eb09
10 changed files with 338 additions and 490 deletions

View File

@@ -6,8 +6,8 @@
<!-- HOME 메뉴 -->
<button
class="menu-btn"
:class="{ active: modelValue === 'home' }"
@click="onMenuClick('home')"
:class="{ active: modelValue === 'HOME' }"
@click="onMenuClick('HOME')"
>
HOME
</button>
@@ -72,7 +72,6 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "~/stores/user";
import { usePermissionsStore } from "~/stores/permissions";
@@ -80,15 +79,9 @@ import { usePermissionsStore } from "~/stores/permissions";
const modelValue = defineModel({ type: String, required: true });
const showDropdown = ref(false);
const router = useRouter();
const userStore = useUserStore();
const permissionStore = usePermissionsStore();
// 권한 초기화
onMounted(async () => {
await permissionStore.fetchPermissions();
});
// 권한이 있고 메뉴에 표시할 페이지그룹들만 필터링
const availableMenus = computed(() => {
return permissionStore.permissions.resources.pageGroups.filter(pageGroup => {
@@ -117,8 +110,7 @@ function handleClickOutside(event) {
async function logout() {
showDropdown.value = false;
await userStore.logout();
router.push("/login");
userStore.logout();
}
onMounted(() => {

View File

@@ -0,0 +1,66 @@
<template>
<nav
v-if="showSubmenuBar && subMenus.length > 0"
class="w-full bg-gray-100 shadow-sm px-4 py-2"
>
<div class="flex items-center space-x-6">
<span class="text-sm font-medium text-gray-600 mr-4">
{{ activeMenu }}
</span>
<div class="flex space-x-4">
<button
v-for="sub in subMenus"
:key="sub.key"
class="submenu-btn"
@click="onSubMenuClick(sub)"
>
{{ sub.label }}
</button>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
interface SubMenu {
key: string;
label: string;
to: string;
componentName: string;
}
interface Props {
showSubmenuBar: boolean;
activeMenu: string;
subMenus: SubMenu[];
}
interface Emits {
(e: "submenu-click", sub: SubMenu): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
function onSubMenuClick(sub: SubMenu) {
emit("submenu-click", sub);
}
</script>
<style scoped>
.submenu-btn {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
color: #374151;
background: none;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.submenu-btn:hover {
color: #2563eb;
background-color: #eff6ff;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="tab-bar">
<div
v-for="tab in tabsStore.tabs"
:key="tab.key"
class="tab-item"
:class="{ active: tabsStore.activeTab === tab.key }"
@click="handleTabClick(tab.key)"
>
<span class="tab-label">{{ tab.label }}</span>
<button
v-if="tabsStore.activeTab !== tab.key"
class="close-btn"
type="button"
@click.stop="handleTabClose(tab.key)"
>
×
</button>
</div>
<button class="add-tab-btn" type="button" @click="handleAddTab"></button>
</div>
</template>
<script setup lang="ts">
import { useTabsStore } from "@/stores/tab";
const tabsStore = useTabsStore();
const handleTabClick = (tabKey: number) => tabsStore.setActiveTab(tabKey);
const handleTabClose = (tabKey: number) => tabsStore.removeTab(tabKey);
const handleAddTab = () => tabsStore.addTab();
</script>
<style scoped>
.tab-bar {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tab-bar::-webkit-scrollbar {
display: none;
}
.tab-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
min-width: fit-content;
position: relative;
}
.tab-item:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.tab-item.active {
background: #3b82f6;
border-color: #3b82f6;
color: #ffffff;
}
.tab-item.active:hover {
background: #2563eb;
border-color: #2563eb;
}
.tab-label {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
line-height: 1;
color: inherit;
transition: background-color 0.2s ease;
flex-shrink: 0;
}
.close-btn:hover {
background: rgba(0, 0, 0, 0.1);
}
.tab-item.active .close-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.add-tab-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1.125rem;
line-height: 1;
color: #64748b;
transition: all 0.2s ease;
flex-shrink: 0;
min-width: 2.5rem;
}
.add-tab-btn:hover {
background: #e2e8f0;
border-color: #cbd5e1;
color: #475569;
}
</style>

View File

@@ -7,17 +7,20 @@
* @returns Promise<T> - API 응답 데이터
*
* @example
* // GET 요청 (기본)
* // GET 요청 (기본 - 전역 로딩 자동 적용)
* const users = await useApi<User[]>('/users')
*
* // POST 요청
* // POST 요청 (커스텀 로딩 메시지)
* const newUser = await useApi<User>('/users', {
* method: 'POST',
* body: { name: 'John', email: 'john@example.com' }
* body: { name: 'John', email: 'john@example.com' },
* loadingMessage: '사용자를 생성하는 중...'
* })
*
* // 에러 시 alert 표시
* const data = await useApi<User[]>('/users', { showAlert: true })
* // 전역 로딩 없이 API 호출
* const data = await useApi<User[]>('/users', {
* useGlobalLoading: false
* })
*
* // 에러를 직접 처리
* try {
@@ -29,7 +32,11 @@
* // FormData 업로드
* const formData = new FormData()
* formData.append('file', file)
* await useApi('/upload', { method: 'POST', body: formData })
* await useApi('/upload', {
* method: 'POST',
* body: formData,
* loadingMessage: '파일을 업로드하는 중...'
* })
*/
export const useApi = async <T>(
path: string,
@@ -41,50 +48,72 @@ export const useApi = async <T>(
// 에러 처리 옵션
handleError?: boolean; // true: 에러를 null로 반환, false: 에러를 다시 던짐
showAlert?: boolean; // true: 에러 시 alert 표시
// 로딩 옵션
loadingMessage?: string; // 로딩 메시지
useGlobalLoading?: boolean; // 전역 로딩 사용 여부 (기본값: true)
}
): Promise<T> => {
const { $api } = useNuxtApp();
const { withLoading } = useLoading();
// 기본값 설정
const {
method = "GET",
body,
headers,
handleError = true,
showAlert = true,
} = options || {};
// API 호출 로직을 별도 함수로 분리
const apiCall = async (): Promise<T> => {
const { $api } = useNuxtApp();
// API 요청 옵션 구성
const apiOpts = {
method,
...(body && { body }),
...(headers && { headers }),
};
// 기본값 설정
const {
method = "GET",
body,
headers,
handleError = true,
showAlert = true,
} = options || {};
return ($api as any)(path, apiOpts).catch((error: any) => {
// 사용자에게 알림 표시
if (showAlert) {
const status = error.response?.status;
let message =
status === 404
? "요청한 리소스를 찾을 수 없습니다."
: status === 500
? "서버 오류가 발생했습니다."
: "요청 처리 중 오류가 발생했습니다.";
// API 요청 옵션 구성
const apiOpts = {
method,
...(body && { body }),
...(headers && { headers }),
};
// 서버에서 온 에러 메시지가 있으면 우선 사용
if (error.response?._data?.description) {
message = error.response._data.description;
return ($api as any)(path, apiOpts).catch((error: any) => {
// 사용자에게 알림 표시
if (showAlert) {
const status = error.response?.status;
let message =
status === 404
? "요청한 리소스를 찾을 수 없습니다."
: status === 500
? "서버 오류가 발생했습니다."
: "요청 처리 중 오류가 발생했습니다.";
// 서버에서 온 에러 메시지가 있으면 우선 사용
if (error.response?._data?.description) {
message = error.response._data.description;
}
alert(message);
}
alert(message);
}
// 에러 처리 방식에 따라 반환
if (handleError) {
return null as T; // 에러를 null로 반환
} else {
throw error; // 에러를 다시 던짐
}
});
};
// 에러 처리 방식에 따라 반환
if (handleError) {
return null as T; // 에러를 null로 반환
} else {
throw error; // 에러를 다시 던짐
}
});
// 전역 로딩 사용 여부 확인 (기본값: true)
const shouldUseLoading = options?.useGlobalLoading !== false;
if (shouldUseLoading) {
// 전역 로딩과 함께 API 호출
return await withLoading(
apiCall,
options?.loadingMessage || "데이터를 불러오는 중..."
);
} else {
// 전역 로딩 없이 API 호출
return await apiCall();
}
};

View File

@@ -1,6 +1,28 @@
<template>
<div class="layout">
<AppHeader v-model="activeMenu" @update:model-value="handleMenuChange" />
<!-- 서브메뉴 -->
<SubMenuBar
:show-submenu-bar="showSubmenuBar"
:active-menu="activeMenu"
:sub-menus="subMenus"
@submenu-click="onSubMenuClick"
/>
<!-- -->
<TabBar />
<main class="main">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
import AppHeader from "../components/layout/AppHeader.vue";
import { ref, computed, watch, onMounted } from "vue";
import SubMenuBar from "../components/layout/SubMenuBar.vue";
import TabBar from "../components/layout/TabBar.vue";
import { ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useTabsStore } from "../stores/tab";
import { usePermissionsStore } from "~/stores/permissions";
@@ -12,16 +34,6 @@ const showSubmenuBar = ref(false);
const tabsStore = useTabsStore();
const permissionStore = usePermissionsStore();
// 권한 초기화
onMounted(async () => {
await permissionStore.fetchPermissions();
});
// 메뉴 클릭 시 홈 이동
watch(activeMenu, newValue => {
if (newValue === "HOME") router.push("/");
});
// 권한 기반 서브메뉴 생성
const subMenus = computed(() => {
if (activeMenu.value === "HOME") return [];
@@ -43,11 +55,15 @@ const subMenus = computed(() => {
}));
});
function onMenuClick(menu: string) {
showSubmenuBar.value = menu !== "home";
function handleMenuChange(_menuCode: string) {
if (activeMenu.value === "HOME") {
showSubmenuBar.value = false;
router.push("/");
} else {
showSubmenuBar.value = true;
}
}
// ✅ 서브메뉴 클릭 → 현재 활성 탭 내용만 변경
function onSubMenuClick(sub: {
key: string;
label: string;
@@ -55,72 +71,9 @@ function onSubMenuClick(sub: {
componentName: string;
}) {
tabsStore.updateActiveTab(sub);
// const activeKey = tabsStore.activeTab;
// router.push(`/${activeKey}${sub.to}`);
}
// ✅ 새 탭 추가 버튼
function addNewTab() {
tabsStore.addTab();
// router.push(`/${key}/`);
}
</script>
<template>
<div class="layout">
<AppHeader v-model="activeMenu" @update:model-value="onMenuClick" />
<!-- 서브메뉴 -->
<nav
v-if="showSubmenuBar && subMenus.length > 0"
class="w-full bg-gray-100 shadow-sm px-4 py-2"
>
<div class="flex items-center space-x-6">
<span class="text-sm font-medium text-gray-600 mr-4">
{{ activeMenu }}
</span>
<div class="flex space-x-4">
<button
v-for="sub in subMenus"
:key="sub.key"
class="submenu-btn"
@click="onSubMenuClick(sub)"
>
{{ sub.label }}
</button>
</div>
</div>
</nav>
<br /><br />
<!-- -->
<div class="tab-bar">
<div
v-for="tab in tabsStore.tabs"
:key="tab.key"
class="tab-item"
:class="{ active: tabsStore.activeTab === tab.key }"
@click="tabsStore.setActiveTab(tab.key)"
>
{{ tab.label }}
<span
v-show="tabsStore.activeTab !== tab.key"
class="close-btn"
@click.stop="tabsStore.removeTab(tab.key)"
>
×
</span>
</div>
<!-- 추가 버튼 -->
<button class="add-tab-btn" @click="addNewTab"></button>
</div>
<main class="main">
<slot />
</main>
</div>
</template>
<style scoped>
.layout {
min-height: 100vh;
@@ -139,56 +92,4 @@ function addNewTab() {
text-align: center;
border-top: 1px solid #e9ecef;
}
.submenu-bar {
background: #f4f6fa;
border-bottom: 1px solid #e0e7ef;
padding: 0.5rem 2rem;
display: flex;
gap: 1rem;
position: absolute;
top: 80px;
left: 0;
right: 0;
z-index: 10;
}
.submenu-btn {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
color: #374151;
background: none;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.15s ease;
}
.submenu-btn:hover {
color: #2563eb;
background-color: #eff6ff;
}
/* 탭바 스타일 */
.tab-bar {
display: flex;
gap: 6px;
padding: 0.4rem 0.8rem;
background: #fff;
border-bottom: 1px solid #ddd;
}
.tab-item {
padding: 0.3rem 0.8rem;
background: #f2f2f2;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
}
.tab-item.active {
background: #1976d2;
color: white;
}
.close-btn {
margin-left: 6px;
cursor: pointer;
}
</style>

View File

@@ -1,276 +0,0 @@
<template>
<div class="about">
<section class="hero">
<h1>About Us</h1>
<p class="subtitle">혁신적인 솔루션으로 미래를 만들어갑니다</p>
</section>
<section class="mission">
<h2>Our Mission</h2>
<p>
우리는 최신 기술을 활용하여 사용자 중심의 혁신적인 제품을 개발하고,
나은 디지털 경험을 제공하는 것을 목표로 합니다.
</p>
</section>
<section class="values">
<h2>Our Values</h2>
<div class="values-grid">
<div class="value-card">
<h3>혁신</h3>
<p>끊임없는 혁신을 통해 새로운 가치를 창출합니다</p>
</div>
<div class="value-card">
<h3>품질</h3>
<p>최고의 품질을 위해 세심한 주의를 기울입니다</p>
</div>
<div class="value-card">
<h3>협력</h3>
<p>팀워크와 협력을 통해 성과를 달성합니다</p>
</div>
<div class="value-card">
<h3>성장</h3>
<p>지속적인 학습과 성장을 추구합니다</p>
</div>
</div>
</section>
<section class="team">
<h2>Our Team</h2>
<div class="team-grid">
<div class="team-member">
<div class="member-avatar">
<span>👨💻</span>
</div>
<h3>김개발</h3>
<p class="position">Frontend Developer</p>
<p>
Vue.js와 Nuxt.js 전문가로 사용자 경험에 중점을 개발을 담당합니다.
</p>
</div>
<div class="team-member">
<div class="member-avatar">
<span>👩💻</span>
</div>
<h3>이디자인</h3>
<p class="position">UI/UX Designer</p>
<p>사용자 중심의 직관적이고 아름다운 인터페이스를 설계합니다.</p>
</div>
<div class="team-member">
<div class="member-avatar">
<span>👨🔧</span>
</div>
<h3>박백엔드</h3>
<p class="position">Backend Developer</p>
<p>안정적이고 확장 가능한 서버 아키텍처를 구축합니다.</p>
</div>
</div>
</section>
<section class="contact">
<h2>Contact Us</h2>
<p>궁금한 점이 있으시면 언제든 연락주세요!</p>
<div class="contact-info">
<p>📧 Email: contact@example.com</p>
<p>📱 Phone: 02-1234-5678</p>
<p>📍 Address: 서울특별시 강남구 테헤란로 123</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
// 페이지 메타데이터 설정
definePageMeta({
title: "About",
description: "우리 팀과 미션에 대해 알아보세요",
});
// SEO 최적화
useHead({
title: "About Us - Nuxt.js App",
meta: [
{
name: "description",
content: "혁신적인 솔루션으로 미래를 만들어가는 우리 팀을 소개합니다.",
},
{ property: "og:title", content: "About Us - Nuxt.js App" },
{
property: "og:description",
content: "혁신적인 솔루션으로 미래를 만들어가는 우리 팀을 소개합니다.",
},
],
});
</script>
<style scoped>
.about {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.hero {
text-align: center;
padding: 3rem 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
margin-bottom: 3rem;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
color: white;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
}
section {
margin-bottom: 4rem;
}
h2 {
color: #333;
font-size: 2rem;
margin-bottom: 1.5rem;
text-align: center;
}
.mission {
text-align: center;
padding: 2rem;
background: #f8f9fa;
border-radius: 8px;
}
.mission p {
font-size: 1.1rem;
line-height: 1.6;
color: #666;
max-width: 800px;
margin: 0 auto;
}
.values-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.value-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
transition: transform 0.3s ease;
}
.value-card:hover {
transform: translateY(-5px);
}
.value-card h3 {
color: #00dc82;
margin-bottom: 1rem;
font-size: 1.3rem;
}
.value-card p {
color: #666;
line-height: 1.5;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.team-member {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
transition: transform 0.3s ease;
}
.team-member:hover {
transform: translateY(-5px);
}
.member-avatar {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #00dc82, #00b894);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
font-size: 2rem;
}
.team-member h3 {
color: #333;
margin-bottom: 0.5rem;
}
.position {
color: #00dc82;
font-weight: bold;
margin-bottom: 1rem;
}
.team-member p:last-child {
color: #666;
line-height: 1.5;
}
.contact {
text-align: center;
padding: 2rem;
background: #f8f9fa;
border-radius: 8px;
}
.contact p {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #666;
}
.contact-info {
margin-top: 2rem;
}
.contact-info p {
margin-bottom: 0.5rem;
font-size: 1rem;
}
@media (max-width: 768px) {
.hero h1 {
font-size: 2rem;
}
.values-grid,
.team-grid {
grid-template-columns: 1fr;
}
.hero,
.mission,
.contact {
padding: 1.5rem;
}
}
</style>

View File

@@ -100,12 +100,11 @@
<script setup lang="ts">
import { useUserStore } from "~/stores/user";
import { useRouter } from "vue-router";
const router = useRouter();
onMounted(() => {
router.push("/1/");
});
// onMounted(() => {
// console.log("index.vue - onMounted");
// router.push("/1/");
// });
// 페이지 메타데이터 설정
definePageMeta({

View File

@@ -39,7 +39,7 @@ export const usePermissionsStore = defineStore(
*/
// 임시 가데이터 사용
await new Promise(resolve => setTimeout(resolve, 500)); // 로딩 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 3000)); // 로딩 시뮬레이션
permissions.value = {
resources: {

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia";
interface Tab {
export interface Tab {
key: number; // 1~10
label: string;
to: string; // 페이지 라우트
@@ -46,7 +46,7 @@ export const useTabsStore = defineStore("tabs", {
$router.push(`/${tab?.key}${tab?.to}`);
},
// 활 탭 제거
// 활 탭 제거
removeTab(key: number) {
this.tabs = this.tabs.filter(t => t.key !== key);
if (this.activeTab === key) {
@@ -58,6 +58,7 @@ export const useTabsStore = defineStore("tabs", {
// 활성 탭 변경
setActiveTab(key: number) {
console.log("tab.ts - setActiveTab", key);
const { $router } = useNuxtApp();
this.activeTab = key;
@@ -67,11 +68,12 @@ export const useTabsStore = defineStore("tabs", {
// 탭 초기화
resetTabs() {
const { $router } = useNuxtApp();
this.tabs = [defaultTab];
console.log("tab.ts - resetTabs");
this.tabs = [{ ...defaultTab }];
this.activeTab = 1;
$router.push(defaultTab.to);
console.log("tab.ts - tabs:", this.tabs);
console.log("tab.ts - activeTab:", this.activeTab);
console.log("tab.ts - resetTabs 완료");
},
},
persist: true,

View File

@@ -9,7 +9,6 @@ export const useUserStore = defineStore(
userId?: string;
name?: string;
} | null>(null);
const token = ref<string | null>(null);
// 권한 스토어 참조
const permissionsStore = usePermissionsStore();
@@ -48,37 +47,38 @@ export const useUserStore = defineStore(
}
};
const logout = () => {
user.value = null;
isLoggedIn.value = false;
const logout = async () => {
try {
const response = await useApi("/members/logout", {
method: "post",
loadingMessage: "로그아웃 처리중...",
handleError: false,
showAlert: false,
});
// 권한 데이터 초기화
permissionsStore.clearPermissions();
// 탭 초기화
tabsStore.resetTabs();
console.log("response:", response);
} catch (error) {
console.error(error);
} finally {
user.value = null;
isLoggedIn.value = false;
useApi("/members/logout", { method: "post" });
};
tabsStore.resetTabs();
permissionsStore.clearPermissions();
const setToken = (accessToken: string) => {
token.value = accessToken;
};
const getToken = () => {
return token;
// 로그인 페이지로 이동
await navigateTo("/login");
}
};
return {
// 상태
isLoggedIn,
user,
token,
// 액션
login,
logout,
setToken,
getToken,
};
},
{