[UI 개선] 서브메뉴 및 탭 바 컴포넌트 추가, AppHeader 및 기본 레이아웃 수정, API 호출 로직 개선
This commit is contained in:
@@ -6,8 +6,8 @@
|
|||||||
<!-- HOME 메뉴 -->
|
<!-- HOME 메뉴 -->
|
||||||
<button
|
<button
|
||||||
class="menu-btn"
|
class="menu-btn"
|
||||||
:class="{ active: modelValue === 'home' }"
|
:class="{ active: modelValue === 'HOME' }"
|
||||||
@click="onMenuClick('home')"
|
@click="onMenuClick('HOME')"
|
||||||
>
|
>
|
||||||
HOME
|
HOME
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +72,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useUserStore } from "~/stores/user";
|
import { useUserStore } from "~/stores/user";
|
||||||
import { usePermissionsStore } from "~/stores/permissions";
|
import { usePermissionsStore } from "~/stores/permissions";
|
||||||
|
|
||||||
@@ -80,15 +79,9 @@ import { usePermissionsStore } from "~/stores/permissions";
|
|||||||
const modelValue = defineModel({ type: String, required: true });
|
const modelValue = defineModel({ type: String, required: true });
|
||||||
|
|
||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
const router = useRouter();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const permissionStore = usePermissionsStore();
|
const permissionStore = usePermissionsStore();
|
||||||
|
|
||||||
// 권한 초기화
|
|
||||||
onMounted(async () => {
|
|
||||||
await permissionStore.fetchPermissions();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 권한이 있고 메뉴에 표시할 페이지그룹들만 필터링
|
// 권한이 있고 메뉴에 표시할 페이지그룹들만 필터링
|
||||||
const availableMenus = computed(() => {
|
const availableMenus = computed(() => {
|
||||||
return permissionStore.permissions.resources.pageGroups.filter(pageGroup => {
|
return permissionStore.permissions.resources.pageGroups.filter(pageGroup => {
|
||||||
@@ -117,8 +110,7 @@ function handleClickOutside(event) {
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
await userStore.logout();
|
userStore.logout();
|
||||||
router.push("/login");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
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 응답 데이터
|
* @returns Promise<T> - API 응답 데이터
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // GET 요청 (기본)
|
* // GET 요청 (기본 - 전역 로딩 자동 적용)
|
||||||
* const users = await useApi<User[]>('/users')
|
* const users = await useApi<User[]>('/users')
|
||||||
*
|
*
|
||||||
* // POST 요청
|
* // POST 요청 (커스텀 로딩 메시지)
|
||||||
* const newUser = await useApi<User>('/users', {
|
* const newUser = await useApi<User>('/users', {
|
||||||
* method: 'POST',
|
* method: 'POST',
|
||||||
* body: { name: 'John', email: 'john@example.com' }
|
* body: { name: 'John', email: 'john@example.com' },
|
||||||
|
* loadingMessage: '사용자를 생성하는 중...'
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // 에러 시 alert 표시
|
* // 전역 로딩 없이 API 호출
|
||||||
* const data = await useApi<User[]>('/users', { showAlert: true })
|
* const data = await useApi<User[]>('/users', {
|
||||||
|
* useGlobalLoading: false
|
||||||
|
* })
|
||||||
*
|
*
|
||||||
* // 에러를 직접 처리
|
* // 에러를 직접 처리
|
||||||
* try {
|
* try {
|
||||||
@@ -29,7 +32,11 @@
|
|||||||
* // FormData 업로드
|
* // FormData 업로드
|
||||||
* const formData = new FormData()
|
* const formData = new FormData()
|
||||||
* formData.append('file', file)
|
* formData.append('file', file)
|
||||||
* await useApi('/upload', { method: 'POST', body: formData })
|
* await useApi('/upload', {
|
||||||
|
* method: 'POST',
|
||||||
|
* body: formData,
|
||||||
|
* loadingMessage: '파일을 업로드하는 중...'
|
||||||
|
* })
|
||||||
*/
|
*/
|
||||||
export const useApi = async <T>(
|
export const useApi = async <T>(
|
||||||
path: string,
|
path: string,
|
||||||
@@ -41,8 +48,15 @@ export const useApi = async <T>(
|
|||||||
// 에러 처리 옵션
|
// 에러 처리 옵션
|
||||||
handleError?: boolean; // true: 에러를 null로 반환, false: 에러를 다시 던짐
|
handleError?: boolean; // true: 에러를 null로 반환, false: 에러를 다시 던짐
|
||||||
showAlert?: boolean; // true: 에러 시 alert 표시
|
showAlert?: boolean; // true: 에러 시 alert 표시
|
||||||
|
// 로딩 옵션
|
||||||
|
loadingMessage?: string; // 로딩 메시지
|
||||||
|
useGlobalLoading?: boolean; // 전역 로딩 사용 여부 (기본값: true)
|
||||||
}
|
}
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
|
const { withLoading } = useLoading();
|
||||||
|
|
||||||
|
// API 호출 로직을 별도 함수로 분리
|
||||||
|
const apiCall = async (): Promise<T> => {
|
||||||
const { $api } = useNuxtApp();
|
const { $api } = useNuxtApp();
|
||||||
|
|
||||||
// 기본값 설정
|
// 기본값 설정
|
||||||
@@ -87,4 +101,19 @@ export const useApi = async <T>(
|
|||||||
throw error; // 에러를 다시 던짐
|
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">
|
<script setup lang="ts">
|
||||||
import AppHeader from "../components/layout/AppHeader.vue";
|
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 { useRouter } from "vue-router";
|
||||||
import { useTabsStore } from "../stores/tab";
|
import { useTabsStore } from "../stores/tab";
|
||||||
import { usePermissionsStore } from "~/stores/permissions";
|
import { usePermissionsStore } from "~/stores/permissions";
|
||||||
@@ -12,16 +34,6 @@ const showSubmenuBar = ref(false);
|
|||||||
const tabsStore = useTabsStore();
|
const tabsStore = useTabsStore();
|
||||||
const permissionStore = usePermissionsStore();
|
const permissionStore = usePermissionsStore();
|
||||||
|
|
||||||
// 권한 초기화
|
|
||||||
onMounted(async () => {
|
|
||||||
await permissionStore.fetchPermissions();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 메뉴 클릭 시 홈 이동
|
|
||||||
watch(activeMenu, newValue => {
|
|
||||||
if (newValue === "HOME") router.push("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
// 권한 기반 서브메뉴 생성
|
// 권한 기반 서브메뉴 생성
|
||||||
const subMenus = computed(() => {
|
const subMenus = computed(() => {
|
||||||
if (activeMenu.value === "HOME") return [];
|
if (activeMenu.value === "HOME") return [];
|
||||||
@@ -43,11 +55,15 @@ const subMenus = computed(() => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
function onMenuClick(menu: string) {
|
function handleMenuChange(_menuCode: string) {
|
||||||
showSubmenuBar.value = menu !== "home";
|
if (activeMenu.value === "HOME") {
|
||||||
|
showSubmenuBar.value = false;
|
||||||
|
router.push("/");
|
||||||
|
} else {
|
||||||
|
showSubmenuBar.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 서브메뉴 클릭 → 현재 활성 탭 내용만 변경
|
|
||||||
function onSubMenuClick(sub: {
|
function onSubMenuClick(sub: {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -55,72 +71,9 @@ function onSubMenuClick(sub: {
|
|||||||
componentName: string;
|
componentName: string;
|
||||||
}) {
|
}) {
|
||||||
tabsStore.updateActiveTab(sub);
|
tabsStore.updateActiveTab(sub);
|
||||||
// const activeKey = tabsStore.activeTab;
|
|
||||||
// router.push(`/${activeKey}${sub.to}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 새 탭 추가 버튼
|
|
||||||
function addNewTab() {
|
|
||||||
tabsStore.addTab();
|
|
||||||
// router.push(`/${key}/`);
|
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
<style scoped>
|
||||||
.layout {
|
.layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -139,56 +92,4 @@ function addNewTab() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
border-top: 1px solid #e9ecef;
|
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>
|
</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">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from "~/stores/user";
|
import { useUserStore } from "~/stores/user";
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
onMounted(() => {
|
// onMounted(() => {
|
||||||
router.push("/1/");
|
// console.log("index.vue - onMounted");
|
||||||
});
|
// router.push("/1/");
|
||||||
|
// });
|
||||||
|
|
||||||
// 페이지 메타데이터 설정
|
// 페이지 메타데이터 설정
|
||||||
definePageMeta({
|
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 = {
|
permissions.value = {
|
||||||
resources: {
|
resources: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
interface Tab {
|
export interface Tab {
|
||||||
key: number; // 1~10
|
key: number; // 1~10
|
||||||
label: string;
|
label: string;
|
||||||
to: string; // 페이지 라우트
|
to: string; // 페이지 라우트
|
||||||
@@ -46,7 +46,7 @@ export const useTabsStore = defineStore("tabs", {
|
|||||||
$router.push(`/${tab?.key}${tab?.to}`);
|
$router.push(`/${tab?.key}${tab?.to}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 활설 탭 제거
|
// 활성 탭 제거
|
||||||
removeTab(key: number) {
|
removeTab(key: number) {
|
||||||
this.tabs = this.tabs.filter(t => t.key !== key);
|
this.tabs = this.tabs.filter(t => t.key !== key);
|
||||||
if (this.activeTab === key) {
|
if (this.activeTab === key) {
|
||||||
@@ -58,6 +58,7 @@ export const useTabsStore = defineStore("tabs", {
|
|||||||
|
|
||||||
// 활성 탭 변경
|
// 활성 탭 변경
|
||||||
setActiveTab(key: number) {
|
setActiveTab(key: number) {
|
||||||
|
console.log("tab.ts - setActiveTab", key);
|
||||||
const { $router } = useNuxtApp();
|
const { $router } = useNuxtApp();
|
||||||
this.activeTab = key;
|
this.activeTab = key;
|
||||||
|
|
||||||
@@ -67,11 +68,12 @@ export const useTabsStore = defineStore("tabs", {
|
|||||||
|
|
||||||
// 탭 초기화
|
// 탭 초기화
|
||||||
resetTabs() {
|
resetTabs() {
|
||||||
const { $router } = useNuxtApp();
|
console.log("tab.ts - resetTabs");
|
||||||
|
this.tabs = [{ ...defaultTab }];
|
||||||
this.tabs = [defaultTab];
|
|
||||||
this.activeTab = 1;
|
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,
|
persist: true,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export const useUserStore = defineStore(
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const token = ref<string | null>(null);
|
|
||||||
|
|
||||||
// 권한 스토어 참조
|
// 권한 스토어 참조
|
||||||
const permissionsStore = usePermissionsStore();
|
const permissionsStore = usePermissionsStore();
|
||||||
@@ -48,37 +47,38 @@ export const useUserStore = defineStore(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
const response = await useApi("/members/logout", {
|
||||||
|
method: "post",
|
||||||
|
loadingMessage: "로그아웃 처리중...",
|
||||||
|
handleError: false,
|
||||||
|
showAlert: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("response:", response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
user.value = null;
|
user.value = null;
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
|
|
||||||
// 권한 데이터 초기화
|
|
||||||
permissionsStore.clearPermissions();
|
|
||||||
// 탭 초기화
|
|
||||||
tabsStore.resetTabs();
|
tabsStore.resetTabs();
|
||||||
|
permissionsStore.clearPermissions();
|
||||||
|
|
||||||
useApi("/members/logout", { method: "post" });
|
// 로그인 페이지로 이동
|
||||||
};
|
await navigateTo("/login");
|
||||||
|
}
|
||||||
const setToken = (accessToken: string) => {
|
|
||||||
token.value = accessToken;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getToken = () => {
|
|
||||||
return token;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 상태
|
// 상태
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
user,
|
user,
|
||||||
token,
|
|
||||||
|
|
||||||
// 액션
|
// 액션
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
setToken,
|
|
||||||
getToken,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user