[UI 개선] 서브메뉴 및 탭 바 컴포넌트 추가, AppHeader 및 기본 레이아웃 수정, API 호출 로직 개선
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
66
components/layout/SubMenuBar.vue
Normal file
66
components/layout/SubMenuBar.vue
Normal 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>
|
||||
135
components/layout/TabBar.vue
Normal file
135
components/layout/TabBar.vue
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
276
pages/about.vue
276
pages/about.vue
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user