login store menu function edit
This commit is contained in:
parent
59e8ecfc24
commit
9e7eb7da2b
@ -1,658 +1,301 @@
|
||||
//* src/components/layout/MainLayout.vue
|
||||
<template>
|
||||
<v-app>
|
||||
<!-- 메인 네비게이션 (데스크톱용 사이드바) -->
|
||||
<Sidebar
|
||||
v-if="!isMobile"
|
||||
v-model="drawer"
|
||||
:rail="rail"
|
||||
:permanent="!isMobile"
|
||||
:temporary="isMobile"
|
||||
:menu-groups="menuGroups"
|
||||
:user-name="userStore.user?.name || '사용자'"
|
||||
:business-name="userStore.user?.businessName || ''"
|
||||
:user-avatar="userStore.user?.avatar || ''"
|
||||
@menu-click="handleMenuClick"
|
||||
@action-click="handleSidebarAction"
|
||||
@update:rail="rail = $event"
|
||||
/>
|
||||
|
||||
<!-- 모바일용 임시 사이드바 -->
|
||||
<Sidebar
|
||||
v-if="isMobile"
|
||||
v-model="drawer"
|
||||
:temporary="true"
|
||||
:menu-groups="menuGroups"
|
||||
:user-name="userStore.user?.name || '사용자'"
|
||||
:business-name="userStore.user?.businessName || ''"
|
||||
:user-avatar="userStore.user?.avatar || ''"
|
||||
@menu-click="handleMenuClick"
|
||||
@action-click="handleSidebarAction"
|
||||
/>
|
||||
|
||||
<!-- 상단 앱바 -->
|
||||
<AppHeader
|
||||
v-if="showHeader"
|
||||
:title="currentPageTitle"
|
||||
:show-back-button="showBackButton"
|
||||
:show-menu-button="isMobile"
|
||||
:show-user-menu="!isMobile"
|
||||
:actions="headerActions"
|
||||
:user-name="userStore.user?.name || '사용자'"
|
||||
:user-email="userStore.user?.email || ''"
|
||||
:user-avatar="userStore.user?.avatar || ''"
|
||||
@toggle-drawer="drawer = !drawer"
|
||||
@back-click="handleBackClick"
|
||||
@action-click="handleHeaderAction"
|
||||
@user-menu-click="handleUserMenuClick"
|
||||
/>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<v-main :class="mainClass">
|
||||
<v-container
|
||||
:fluid="fluidContainer"
|
||||
:class="containerClass"
|
||||
>
|
||||
<!-- 로딩 오버레이 -->
|
||||
<LoadingSpinner
|
||||
v-if="loading"
|
||||
overlay
|
||||
:message="loadingMessage"
|
||||
:sub-message="loadingSubMessage"
|
||||
/>
|
||||
|
||||
<!-- 에러 알림 -->
|
||||
<ErrorAlert
|
||||
v-if="error"
|
||||
v-model="showError"
|
||||
:type="error.type || 'error'"
|
||||
:title="error.title"
|
||||
:message="error.message"
|
||||
:details="error.details"
|
||||
:show-retry-button="error.retryable"
|
||||
@retry="handleErrorRetry"
|
||||
@close="clearError"
|
||||
/>
|
||||
|
||||
<!-- 페이지 컨텐츠 -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition :name="pageTransition" mode="out-in">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
@loading="setLoading"
|
||||
@error="setError"
|
||||
/>
|
||||
</transition>
|
||||
</router-view>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<!-- 하단 네비게이션 (모바일용) -->
|
||||
<BottomNavigation
|
||||
v-if="isMobile && showBottomNav"
|
||||
:navigation-items="bottomNavItems"
|
||||
:hide-on-routes="bottomNavHideRoutes"
|
||||
@nav-click="handleBottomNavClick"
|
||||
/>
|
||||
|
||||
<!-- 전역 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="snackbar.timeout"
|
||||
:location="snackbar.location"
|
||||
:multi-line="snackbar.multiLine"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
|
||||
<template v-slot:actions>
|
||||
<v-btn
|
||||
v-if="snackbar.action"
|
||||
variant="text"
|
||||
@click="handleSnackbarAction"
|
||||
>
|
||||
{{ snackbar.actionText }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@click="snackbar.show = false"
|
||||
/>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
|
||||
<!-- 확인 다이얼로그 -->
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.show"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:icon="confirmDialog.icon"
|
||||
:confirm-text="confirmDialog.confirmText"
|
||||
:cancel-text="confirmDialog.cancelText"
|
||||
:confirm-loading="confirmDialog.loading"
|
||||
@confirm="handleConfirmDialog"
|
||||
@cancel="confirmDialog.show = false"
|
||||
/>
|
||||
</v-app>
|
||||
</template>
|
||||
<!-- src/components/layout/MainLayout.vue 또는 네비게이션 컴포넌트 수정 -->
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useStoreStore } from '@/store/index'
|
||||
import { useAppStore } from '@/store/app'
|
||||
|
||||
// 컴포넌트 임포트
|
||||
import AppHeader from '../common/AppHeader.vue'
|
||||
import LoadingSpinner from '../common/LoadingSpinner.vue'
|
||||
import ErrorAlert from '../common/ErrorAlert.vue'
|
||||
import ConfirmDialog from '../common/ConfirmDialog.vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import BottomNavigation from './BottomNavigation.vue'
|
||||
|
||||
// 스토어 (Pinia 사용 가정)
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
/**
|
||||
* AI 마케팅 서비스 - 메인 레이아웃 컴포넌트
|
||||
* 전체 애플리케이션의 레이아웃을 관리하는 최상위 컴포넌트
|
||||
*/
|
||||
|
||||
// Props 정의
|
||||
const props = defineProps({
|
||||
// 레이아웃 설정
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showBottomNav: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
fluidContainer: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
pageTransition: {
|
||||
type: String,
|
||||
default: 'fade'
|
||||
},
|
||||
|
||||
// 커스텀 클래스
|
||||
containerClass: {
|
||||
type: [String, Array, Object],
|
||||
default: 'pa-4'
|
||||
}
|
||||
})
|
||||
|
||||
// 스토어
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 라우터
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const storeStore = useStoreStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Vuetify Display
|
||||
const { mobile } = useDisplay()
|
||||
|
||||
// Reactive data
|
||||
const drawer = ref(false)
|
||||
const rail = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const loadingSubMessage = ref('')
|
||||
const error = ref(null)
|
||||
const showError = ref(false)
|
||||
|
||||
// 스낵바 상태
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'info',
|
||||
timeout: 4000,
|
||||
location: 'bottom',
|
||||
multiLine: false,
|
||||
action: null,
|
||||
actionText: '확인'
|
||||
})
|
||||
|
||||
// 확인 다이얼로그 상태
|
||||
const confirmDialog = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
icon: '',
|
||||
confirmText: '확인',
|
||||
cancelText: '취소',
|
||||
loading: false,
|
||||
callback: null
|
||||
})
|
||||
|
||||
// Computed
|
||||
const isMobile = computed(() => mobile.value)
|
||||
|
||||
const mainClass = computed(() => ({
|
||||
'main-content': true,
|
||||
'main-content--rail': rail.value && !isMobile.value,
|
||||
'main-content--mobile': isMobile.value
|
||||
}))
|
||||
|
||||
const showBackButton = computed(() => {
|
||||
// 메인 페이지가 아닌 경우 뒤로가기 버튼 표시
|
||||
const mainRoutes = ['/dashboard', '/store', '/content', '/menu']
|
||||
return !mainRoutes.includes(route.path)
|
||||
})
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
return route.meta?.title || 'AI 마케팅'
|
||||
})
|
||||
|
||||
const headerActions = computed(() => {
|
||||
// 라우트별 헤더 액션 버튼들
|
||||
const actions = []
|
||||
|
||||
if (route.path.startsWith('/content')) {
|
||||
actions.push({
|
||||
id: 'add-content',
|
||||
icon: 'mdi-plus',
|
||||
text: '생성',
|
||||
color: 'white'
|
||||
})
|
||||
}
|
||||
|
||||
// 알림 버튼
|
||||
actions.push({
|
||||
id: 'notifications',
|
||||
icon: 'mdi-bell',
|
||||
badge: true,
|
||||
badgeCount: appStore.notificationCount || 0,
|
||||
color: 'white'
|
||||
})
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
// 메뉴 구성
|
||||
const menuGroups = computed(() => [
|
||||
{
|
||||
title: '대시보드',
|
||||
items: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: '홈',
|
||||
icon: 'mdi-home',
|
||||
route: '/dashboard'
|
||||
}
|
||||
]
|
||||
},
|
||||
// ✅ 매장 정보 유무에 따른 동적 메뉴 생성
|
||||
const navigationItems = computed(() => {
|
||||
const baseItems = [
|
||||
{
|
||||
title: '매장 관리',
|
||||
items: [
|
||||
{
|
||||
id: 'store',
|
||||
title: '매장 정보',
|
||||
icon: 'mdi-store',
|
||||
route: '/store'
|
||||
},
|
||||
{
|
||||
id: 'menu',
|
||||
title: '메뉴 관리',
|
||||
icon: 'mdi-food',
|
||||
route: '/menu'
|
||||
to: '/store',
|
||||
color: 'primary',
|
||||
available: true, // 항상 접근 가능
|
||||
description: '매장 정보를 등록하고 관리하세요'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// ✅ 매장 정보가 있을 때만 추가되는 메뉴들
|
||||
if (storeStore.hasStoreInfo) {
|
||||
baseItems.unshift( // 대시보드를 맨 앞에 추가
|
||||
{
|
||||
title: '마케팅',
|
||||
items: [
|
||||
title: '대시보드',
|
||||
icon: 'mdi-view-dashboard',
|
||||
to: '/dashboard',
|
||||
color: 'success',
|
||||
available: true,
|
||||
description: '매출 현황과 주요 지표를 확인하세요'
|
||||
}
|
||||
)
|
||||
|
||||
baseItems.push(
|
||||
{
|
||||
id: 'content-create',
|
||||
title: '콘텐츠 생성',
|
||||
icon: 'mdi-plus-circle',
|
||||
route: '/content/create'
|
||||
to: '/content/create',
|
||||
color: 'orange',
|
||||
available: true,
|
||||
description: 'AI로 마케팅 콘텐츠를 생성하세요'
|
||||
},
|
||||
{
|
||||
id: 'content-list',
|
||||
title: '콘텐츠 목록',
|
||||
icon: 'mdi-file-document-multiple',
|
||||
route: '/content'
|
||||
},
|
||||
{
|
||||
id: 'ai-recommend',
|
||||
title: 'AI 추천',
|
||||
icon: 'mdi-robot',
|
||||
route: '/recommend'
|
||||
title: '콘텐츠 관리',
|
||||
icon: 'mdi-folder-multiple',
|
||||
to: '/content',
|
||||
color: 'purple',
|
||||
available: true,
|
||||
description: '생성된 콘텐츠를 관리하고 발행하세요'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '분석',
|
||||
items: [
|
||||
{
|
||||
id: 'sales',
|
||||
title: '매출 분석',
|
||||
icon: 'mdi-chart-line',
|
||||
route: '/sales'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
// 하단 네비게이션 아이템들
|
||||
const bottomNavItems = computed(() => [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: '홈',
|
||||
icon: 'mdi-home-outline',
|
||||
activeIcon: 'mdi-home',
|
||||
route: '/dashboard'
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
label: '매장',
|
||||
icon: 'mdi-store-outline',
|
||||
activeIcon: 'mdi-store',
|
||||
route: '/store'
|
||||
},
|
||||
{
|
||||
id: 'content-create',
|
||||
label: '생성',
|
||||
icon: 'mdi-plus-circle-outline',
|
||||
activeIcon: 'mdi-plus-circle',
|
||||
route: '/content/create'
|
||||
},
|
||||
{
|
||||
id: 'content-list',
|
||||
label: '목록',
|
||||
icon: 'mdi-file-document-outline',
|
||||
activeIcon: 'mdi-file-document',
|
||||
route: '/content'
|
||||
},
|
||||
{
|
||||
id: 'more',
|
||||
label: '더보기',
|
||||
icon: 'mdi-menu',
|
||||
route: '/more'
|
||||
}
|
||||
])
|
||||
|
||||
const bottomNavHideRoutes = ['/login', '/register']
|
||||
|
||||
// Methods
|
||||
const setLoading = (isLoading, message = '', subMessage = '') => {
|
||||
loading.value = isLoading
|
||||
loadingMessage.value = message
|
||||
loadingSubMessage.value = subMessage
|
||||
}
|
||||
|
||||
const setError = (errorData) => {
|
||||
error.value = errorData
|
||||
showError.value = true
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
showError.value = false
|
||||
}
|
||||
|
||||
const handleErrorRetry = () => {
|
||||
if (error.value?.retryCallback) {
|
||||
error.value.retryCallback()
|
||||
}
|
||||
clearError()
|
||||
}
|
||||
|
||||
const showSnackbar = (message, options = {}) => {
|
||||
snackbar.value = {
|
||||
show: true,
|
||||
message,
|
||||
color: options.color || 'info',
|
||||
timeout: options.timeout || 4000,
|
||||
location: options.location || 'bottom',
|
||||
multiLine: options.multiLine || false,
|
||||
action: options.action || null,
|
||||
actionText: options.actionText || '확인'
|
||||
}
|
||||
}
|
||||
|
||||
const handleSnackbarAction = () => {
|
||||
if (snackbar.value.action) {
|
||||
snackbar.value.action()
|
||||
}
|
||||
snackbar.value.show = false
|
||||
}
|
||||
|
||||
const showConfirm = (title, message, callback, options = {}) => {
|
||||
confirmDialog.value = {
|
||||
show: true,
|
||||
title,
|
||||
message,
|
||||
icon: options.icon || 'mdi-help-circle',
|
||||
confirmText: options.confirmText || '확인',
|
||||
cancelText: options.cancelText || '취소',
|
||||
loading: false,
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDialog = () => {
|
||||
if (confirmDialog.value.callback) {
|
||||
confirmDialog.value.loading = true
|
||||
|
||||
try {
|
||||
const result = confirmDialog.value.callback()
|
||||
|
||||
// Promise 처리
|
||||
if (result && typeof result.then === 'function') {
|
||||
result
|
||||
.then(() => {
|
||||
confirmDialog.value.show = false
|
||||
confirmDialog.value.loading = false
|
||||
})
|
||||
.catch((error) => {
|
||||
confirmDialog.value.loading = false
|
||||
setError({
|
||||
title: '오류 발생',
|
||||
message: error.message || '작업 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
})
|
||||
})
|
||||
} else {
|
||||
confirmDialog.value.show = false
|
||||
confirmDialog.value.loading = false
|
||||
}
|
||||
} catch (error) {
|
||||
confirmDialog.value.loading = false
|
||||
setError({
|
||||
title: '오류 발생',
|
||||
message: error.message || '작업 중 오류가 발생했습니다.',
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
confirmDialog.value.show = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackClick = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
const handleMenuClick = (item) => {
|
||||
console.log('Menu clicked:', item)
|
||||
|
||||
// 모바일에서는 메뉴 클릭 시 drawer 닫기
|
||||
if (isMobile.value) {
|
||||
drawer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSidebarAction = (action) => {
|
||||
switch (action.id) {
|
||||
case 'settings':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'logout':
|
||||
showConfirm(
|
||||
'로그아웃',
|
||||
'정말 로그아웃하시겠습니까?',
|
||||
() => {
|
||||
return userStore.logout().then(() => {
|
||||
router.push('/login')
|
||||
showSnackbar('로그아웃되었습니다.', { color: 'success' })
|
||||
})
|
||||
},
|
||||
{ icon: 'mdi-logout' }
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleHeaderAction = (action) => {
|
||||
switch (action.id) {
|
||||
case 'add-content':
|
||||
router.push('/content/create')
|
||||
break
|
||||
case 'notifications':
|
||||
router.push('/notifications')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserMenuClick = (menuItem) => {
|
||||
switch (menuItem.id) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'logout':
|
||||
handleSidebarAction({ id: 'logout' })
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleBottomNavClick = (item) => {
|
||||
console.log('Bottom nav clicked:', item)
|
||||
}
|
||||
|
||||
// 모바일 방향 변경 감지
|
||||
const handleOrientationChange = () => {
|
||||
// 방향 변경 시 drawer 닫기
|
||||
if (isMobile.value) {
|
||||
drawer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 외부에서 접근 가능한 메서드들을 expose
|
||||
defineExpose({
|
||||
setLoading,
|
||||
setError,
|
||||
clearError,
|
||||
showSnackbar,
|
||||
showConfirm
|
||||
return baseItems
|
||||
})
|
||||
|
||||
// 라이프사이클
|
||||
onMounted(() => {
|
||||
// 모바일 방향 변경 리스너 등록
|
||||
window.addEventListener('orientationchange', handleOrientationChange)
|
||||
// ✅ 현재 사용할 수 없는 메뉴들 (매장 정보가 없을 때)
|
||||
const unavailableItems = computed(() => {
|
||||
if (storeStore.hasStoreInfo) {
|
||||
return [] // 매장 정보가 있으면 모든 메뉴 사용 가능
|
||||
}
|
||||
|
||||
// 초기 사용자 정보 로드
|
||||
if (!userStore.user) {
|
||||
userStore.fetchUser().catch(error => {
|
||||
setError({
|
||||
title: '사용자 정보 로드 실패',
|
||||
message: '사용자 정보를 불러올 수 없습니다.',
|
||||
details: error.message,
|
||||
retryable: true,
|
||||
retryCallback: () => userStore.fetchUser()
|
||||
})
|
||||
})
|
||||
return [
|
||||
{
|
||||
title: '대시보드',
|
||||
icon: 'mdi-view-dashboard',
|
||||
color: 'grey',
|
||||
disabled: true,
|
||||
description: '매장 정보 등록 후 이용 가능합니다'
|
||||
},
|
||||
{
|
||||
title: '콘텐츠 생성',
|
||||
icon: 'mdi-plus-circle',
|
||||
color: 'grey',
|
||||
disabled: true,
|
||||
description: '매장 정보 등록 후 이용 가능합니다'
|
||||
},
|
||||
{
|
||||
title: '콘텐츠 관리',
|
||||
icon: 'mdi-folder-multiple',
|
||||
color: 'grey',
|
||||
disabled: true,
|
||||
description: '매장 정보 등록 후 이용 가능합니다'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// ✅ 비활성 메뉴 클릭 처리
|
||||
const handleDisabledMenuClick = (item) => {
|
||||
appStore.showSnackbar('매장 정보를 먼저 등록해주세요', 'warning')
|
||||
router.push('/store')
|
||||
}
|
||||
|
||||
// ✅ 매장 정보 상태 감지
|
||||
const storeInfoStatus = computed(() => {
|
||||
if (storeStore.loading) {
|
||||
return {
|
||||
status: 'loading',
|
||||
message: '매장 정보를 확인하는 중...',
|
||||
color: 'orange'
|
||||
}
|
||||
}
|
||||
|
||||
if (storeStore.hasStoreInfo) {
|
||||
return {
|
||||
status: 'complete',
|
||||
message: '매장 정보 등록 완료',
|
||||
color: 'success'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'required',
|
||||
message: '매장 정보 등록 필요',
|
||||
color: 'warning'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('orientationchange', handleOrientationChange)
|
||||
})
|
||||
|
||||
// 라우트 변경 감지
|
||||
watch(() => route.path, (newPath) => {
|
||||
// 에러 상태 초기화
|
||||
clearError()
|
||||
|
||||
// 모바일에서 라우트 변경 시 drawer 닫기
|
||||
if (isMobile.value) {
|
||||
drawer.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 사용자 상태 변경 감지
|
||||
watch(() => userStore.isAuthenticated, (isAuth) => {
|
||||
if (!isAuth && route.meta?.requiresAuth !== false) {
|
||||
router.push('/login')
|
||||
// 컴포넌트 마운트 시 매장 정보 확인
|
||||
onMounted(async () => {
|
||||
if (authStore.isAuthenticated && !storeStore.hasStoreInfo) {
|
||||
await storeStore.fetchStoreInfo()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<!-- 네비게이션 드로어 -->
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
app
|
||||
:rail="!drawer"
|
||||
rail-width="72"
|
||||
width="300"
|
||||
>
|
||||
<!-- 매장 정보 상태 카드 -->
|
||||
<v-card
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="ma-3 mb-4"
|
||||
:color="storeInfoStatus.color"
|
||||
variant="tonal"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-text class="py-3">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:icon="storeInfoStatus.status === 'loading' ? 'mdi-loading mdi-spin' :
|
||||
storeInfoStatus.status === 'complete' ? 'mdi-check-circle' : 'mdi-alert'"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-body-2 font-weight-medium">
|
||||
{{ storeInfoStatus.message }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 매장명 표시 (매장 정보가 있을 때) -->
|
||||
<div v-if="storeStore.hasStoreInfo && storeStore.storeInfo" class="mt-2">
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ storeStore.storeInfo.storeName }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ storeStore.storeInfo.businessType }}
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="mx-3 mb-2" />
|
||||
|
||||
<!-- 사용 가능한 메뉴들 -->
|
||||
<v-list density="compact" nav>
|
||||
<v-list-item
|
||||
v-for="item in navigationItems"
|
||||
:key="item.title"
|
||||
:to="item.to"
|
||||
:color="item.color"
|
||||
class="mb-1 mx-2"
|
||||
rounded="lg"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :icon="item.icon" />
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-medium">
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
{{ item.description }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!-- 사용할 수 없는 메뉴들 (매장 정보가 없을 때만 표시) -->
|
||||
<template v-if="unavailableItems.length > 0">
|
||||
<v-divider class="mx-3 my-2" />
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-subheader class="text-caption text-medium-emphasis px-4">
|
||||
매장 등록 후 이용 가능
|
||||
</v-list-subheader>
|
||||
|
||||
<v-list-item
|
||||
v-for="item in unavailableItems"
|
||||
:key="item.title"
|
||||
:disabled="item.disabled"
|
||||
class="mb-1 mx-2"
|
||||
rounded="lg"
|
||||
@click="handleDisabledMenuClick(item)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :icon="item.icon" :color="item.color" />
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-medium text-medium-emphasis">
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="text-caption">
|
||||
{{ item.description }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
<v-icon icon="mdi-lock" size="small" color="grey" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<!-- 하단 로그아웃 버튼 -->
|
||||
<template v-slot:append>
|
||||
<v-divider class="mx-3 mb-2" />
|
||||
<div class="pa-3">
|
||||
<v-btn
|
||||
block
|
||||
color="red"
|
||||
variant="outlined"
|
||||
@click="authStore.logout"
|
||||
>
|
||||
<v-icon start>mdi-logout</v-icon>
|
||||
로그아웃
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 앱바 -->
|
||||
<v-app-bar app color="primary" dark elevation="1">
|
||||
<v-app-bar-nav-icon @click="drawer = !drawer" />
|
||||
|
||||
<v-app-bar-title class="font-weight-bold">
|
||||
AI 마케팅
|
||||
</v-app-bar-title>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<!-- 사용자 정보 -->
|
||||
<div v-if="authStore.user" class="d-flex align-center">
|
||||
<v-chip color="white" variant="outlined" class="mr-2">
|
||||
<v-icon start>mdi-account</v-icon>
|
||||
{{ authStore.user.nickname }}님
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-app-bar>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-content {
|
||||
transition: all 0.3s ease;
|
||||
.v-navigation-drawer {
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.main-content--rail {
|
||||
/* Rail 모드일 때 왼쪽 여백 조정 */
|
||||
margin-left: 72px;
|
||||
.v-list-item--active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main-content--mobile {
|
||||
/* 모바일에서는 하단 네비게이션 공간 확보 */
|
||||
padding-bottom: 80px;
|
||||
.mdi-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 페이지 전환 애니메이션 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 960px) {
|
||||
.main-content--rail {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 안전 영역 지원 */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
.v-app-bar {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (padding-bottom: env(safe-area-inset-bottom)) {
|
||||
.main-content--mobile {
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom));
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@ -1,8 +1,5 @@
|
||||
//* src/router/index.js
|
||||
/**
|
||||
* Vue Router 설정
|
||||
* 라우팅 및 네비게이션 가드 설정
|
||||
*/
|
||||
// src/router/index.js - 완전히 수정된 버전
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// 뷰 컴포넌트 lazy loading
|
||||
@ -15,7 +12,7 @@ const ContentManagementView = () => import('@/views/ContentManagementView.vue')
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login', // 항상 로그인 페이지로 먼저 리다이렉트
|
||||
redirect: '/login',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@ -32,6 +29,7 @@ const routes = [
|
||||
component: DashboardView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresStore: true, // ✅ 매장 정보 필수
|
||||
title: '대시보드',
|
||||
},
|
||||
},
|
||||
@ -41,6 +39,7 @@ const routes = [
|
||||
component: StoreManagementView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresStore: false, // ✅ 매장 정보 없어도 접근 가능
|
||||
title: '매장 관리',
|
||||
},
|
||||
},
|
||||
@ -50,6 +49,7 @@ const routes = [
|
||||
component: ContentCreationView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresStore: true, // ✅ 매장 정보 필수
|
||||
title: '콘텐츠 생성',
|
||||
},
|
||||
},
|
||||
@ -59,12 +59,13 @@ const routes = [
|
||||
component: ContentManagementView,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresStore: true, // ✅ 매장 정보 필수
|
||||
title: '콘텐츠 관리',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/login', // 404시 로그인으로 이동
|
||||
redirect: '/login',
|
||||
},
|
||||
]
|
||||
|
||||
@ -73,12 +74,12 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 네비게이션 가드 - 수정된 버전
|
||||
// ✅ 개선된 네비게이션 가드
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
console.log('=== 라우터 가드 실행 ===')
|
||||
console.log('이동 경로:', `${from.path} → ${to.path}`)
|
||||
|
||||
// Pinia 스토어를 동적으로 가져오기 (순환 참조 방지)
|
||||
// Pinia 스토어를 동적으로 가져오기
|
||||
const { useAuthStore } = await import('@/store/auth')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@ -89,19 +90,79 @@ router.beforeEach(async (to, from, next) => {
|
||||
console.log('토큰 존재:', !!authStore.token)
|
||||
console.log('사용자 정보:', authStore.user?.nickname)
|
||||
|
||||
// 인증이 필요한 페이지인지 확인
|
||||
// 1단계: 인증 체크
|
||||
const requiresAuth = to.meta.requiresAuth !== false
|
||||
|
||||
if (requiresAuth && !authStore.isAuthenticated) {
|
||||
console.log('인증 필요 - 로그인 페이지로 이동')
|
||||
console.log('🚫 인증 필요 - 로그인 페이지로 이동')
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
console.log('이미 로그인됨 - 대시보드로 이동')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
console.log('✅ 이미 로그인됨 - 매장 정보 체크 후 리다이렉트')
|
||||
|
||||
// 로그인 상태에서 /login 접근 시 매장 정보에 따라 리다이렉트
|
||||
try {
|
||||
const { useStoreStore } = await import('@/store/index')
|
||||
const storeStore = useStoreStore()
|
||||
const result = await storeStore.fetchStoreInfo()
|
||||
|
||||
if (result.success && result.data) {
|
||||
console.log('🏪 매장 정보 있음 - 대시보드로 이동')
|
||||
next('/dashboard')
|
||||
} else {
|
||||
console.log('이동 허용:', to.path)
|
||||
next()
|
||||
console.log('📝 매장 정보 없음 - 매장 관리로 이동')
|
||||
next('/store')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ 매장 정보 조회 실패 - 매장 관리로 이동')
|
||||
next('/store')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2단계: 매장 정보 체크 (인증된 사용자만)
|
||||
const requiresStore = to.meta.requiresStore === true
|
||||
|
||||
if (authStore.isAuthenticated && requiresStore) {
|
||||
console.log('🏪 매장 정보 체크 필요한 페이지:', to.name)
|
||||
|
||||
try {
|
||||
const { useStoreStore } = await import('@/store/index')
|
||||
const storeStore = useStoreStore()
|
||||
|
||||
// 매장 정보 조회
|
||||
const result = await storeStore.fetchStoreInfo()
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.log('🚫 매장 정보 없음 - 매장 관리 페이지로 리다이렉트')
|
||||
|
||||
// 사용자에게 알림 (스낵바)
|
||||
const { useAppStore } = await import('@/store/app')
|
||||
const appStore = useAppStore()
|
||||
appStore.showSnackbar('매장 정보를 먼저 등록해주세요', 'warning')
|
||||
|
||||
next('/store')
|
||||
return
|
||||
} else {
|
||||
console.log('✅ 매장 정보 확인됨 - 페이지 접근 허용')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ 매장 정보 조회 실패 - 매장 관리 페이지로 리다이렉트')
|
||||
|
||||
// 에러 시에도 매장 관리로
|
||||
const { useAppStore } = await import('@/store/app')
|
||||
const appStore = useAppStore()
|
||||
appStore.showSnackbar('매장 정보를 확인할 수 없습니다. 매장 정보를 등록해주세요', 'error')
|
||||
|
||||
next('/store')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ 이동 허용:', to.path)
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
|
||||
@ -72,12 +72,11 @@ class StoreService {
|
||||
try {
|
||||
console.log('=== 매장 정보 조회 API 호출 ===')
|
||||
|
||||
// URL 슬래시 문제 해결: 빈 문자열로 호출하여 '/api/store'가 되도록 함
|
||||
const response = await storeApi.get('')
|
||||
|
||||
console.log('매장 정보 조회 API 응답:', response.data)
|
||||
|
||||
// 백엔드 응답 구조 수정: 디버깅 결과에 맞게 처리
|
||||
// 성공 응답 처리
|
||||
if (response.data && response.data.status === 200 && response.data.data) {
|
||||
console.log('✅ 매장 정보 조회 성공:', response.data.data)
|
||||
return {
|
||||
@ -87,21 +86,26 @@ class StoreService {
|
||||
}
|
||||
} else if (response.data && response.data.status === 404) {
|
||||
// 매장이 없는 경우
|
||||
console.log('⚠️ 등록된 매장이 없음')
|
||||
console.log('📝 등록된 매장이 없음 (정상)')
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
data: null
|
||||
}
|
||||
} else {
|
||||
console.warn('예상치 못한 응답 구조:', response.data)
|
||||
throw new Error(response.data.message || '매장 정보를 찾을 수 없습니다.')
|
||||
console.log('예상치 못한 응답 구조:', response.data)
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 정보 조회 실패:', error)
|
||||
console.log('매장 정보 조회 중 오류:', error.message)
|
||||
|
||||
// 404 오류 처리 (매장이 없음)
|
||||
// 404 오류 - 매장이 없음 (정상)
|
||||
if (error.response?.status === 404) {
|
||||
console.log('📝 404: 등록된 매장이 없음 (정상)')
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
@ -109,20 +113,35 @@ class StoreService {
|
||||
}
|
||||
}
|
||||
|
||||
// 500 오류 처리 (서버 내부 오류)
|
||||
// 500 오류 - 서버 에러지만 매장이 없어서 발생할 수 있음
|
||||
if (error.response?.status === 500) {
|
||||
console.error('서버 내부 오류 - 백엔드 로그 확인 필요:', error.response?.data)
|
||||
console.log('📝 500: 서버 에러 - 매장 없음으로 간주')
|
||||
return {
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다. 관리자에게 문의하세요.',
|
||||
message: '등록된 매장이 없습니다',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
return handleApiError(error)
|
||||
// 401 오류 - 인증 문제
|
||||
if (error.response?.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
message: '로그인이 필요합니다',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 모든 에러도 매장 없음으로 간주
|
||||
console.log('📝 기타 에러 - 매장 없음으로 간주')
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 정보 수정 (STR-010: 매장 수정)
|
||||
* @param {number} storeId - 매장 ID (현재는 사용하지 않음 - JWT에서 사용자 확인)
|
||||
@ -140,10 +159,8 @@ class StoreService {
|
||||
businessType: storeData.businessType,
|
||||
address: storeData.address,
|
||||
phoneNumber: storeData.phoneNumber,
|
||||
// ✅ 수정: businessHours 필드 처리
|
||||
businessHours: storeData.businessHours || `${storeData.openTime || '09:00'}-${storeData.closeTime || '21:00'}`,
|
||||
// ✅ 수정: closedDays 필드 처리
|
||||
closedDays: storeData.closedDays || storeData.holidays || '',
|
||||
businessHours: storeData.businessHours, // 그대로 전달
|
||||
closedDays: storeData.closedDays, // 그대로 전달
|
||||
seatCount: parseInt(storeData.seatCount) || 0,
|
||||
instaAccounts: storeData.instaAccounts || '',
|
||||
blogAccounts: storeData.blogAccounts || '',
|
||||
|
||||
@ -12,7 +12,25 @@ export const useStoreStore = defineStore('store', {
|
||||
getters: {
|
||||
hasStoreInfo: (state) => !!state.storeInfo,
|
||||
isLoading: (state) => state.loading,
|
||||
hasMenus: (state) => state.menus && state.menus.length > 0
|
||||
hasMenus: (state) => state.menus && state.menus.length > 0,
|
||||
|
||||
storeInfoSummary: (state) => {
|
||||
if (!state.storeInfo) {
|
||||
return {
|
||||
hasStore: false,
|
||||
message: '매장 정보를 등록해주세요',
|
||||
action: '등록하기'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasStore: true,
|
||||
storeName: state.storeInfo.storeName,
|
||||
businessType: state.storeInfo.businessType,
|
||||
message: `${state.storeInfo.storeName} 운영 중`,
|
||||
action: '관리하기'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
@ -44,39 +62,84 @@ export const useStoreStore = defineStore('store', {
|
||||
return { success: true, data: result.data }
|
||||
} else {
|
||||
// 매장이 없거나 조회 실패한 경우
|
||||
console.log('⚠️ 매장 정보 없음 또는 조회 실패')
|
||||
console.log('📝 매장 정보 없음 - 신규 사용자')
|
||||
this.storeInfo = null
|
||||
|
||||
if (result.message === '등록된 매장이 없습니다') {
|
||||
return { success: false, message: '등록된 매장이 없습니다' }
|
||||
} else {
|
||||
return { success: false, message: result.message || '매장 정보 조회에 실패했습니다' }
|
||||
// 매장이 없는 것은 정상 상황이므로 success: false이지만 에러가 아님
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
isNewUser: true // 신규 사용자 플래그 추가
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('=== Store 스토어: 매장 정보 조회 실패 ===')
|
||||
console.error('Error:', error)
|
||||
console.log('=== Store 스토어: 매장 정보 조회 중 오류 ===')
|
||||
console.log('Error:', error.message)
|
||||
|
||||
this.error = error.message
|
||||
this.error = null // 에러 상태를 설정하지 않음
|
||||
this.storeInfo = null
|
||||
|
||||
// HTTP 상태 코드별 처리
|
||||
// HTTP 상태 코드별 처리 - 모두 신규 사용자로 간주
|
||||
if (error.response?.status === 404) {
|
||||
return { success: false, message: '등록된 매장이 없습니다' }
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
isNewUser: true
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status >= 500) {
|
||||
return { success: false, message: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' }
|
||||
// 서버 에러도 신규 사용자로 간주 (매장이 없어서 발생할 수 있음)
|
||||
console.log('서버 에러 발생, 신규 사용자로 간주')
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
isNewUser: true
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
return {
|
||||
success: false,
|
||||
message: '로그인이 필요합니다',
|
||||
needLogin: true
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, message: error.message || '매장 정보 조회에 실패했습니다' }
|
||||
// 기타 모든 에러도 신규 사용자로 간주
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
isNewUser: true
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async getLoginRedirectPath() {
|
||||
try {
|
||||
const result = await this.fetchStoreInfo()
|
||||
|
||||
if (result.success && result.data) {
|
||||
return {
|
||||
path: '/dashboard',
|
||||
message: `${result.data.storeName}에 오신 것을 환영합니다!`,
|
||||
type: 'success'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
path: '/store',
|
||||
message: '매장 정보를 등록하고 AI 마케팅을 시작해보세요!',
|
||||
type: 'info'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
path: '/store',
|
||||
message: '매장 정보를 확인할 수 없습니다. 매장 정보를 등록해주세요',
|
||||
type: 'warning'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// src/store/index.js에서 fetchMenus 부분만 수정
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user