This commit is contained in:
SeoJHeasdw 2025-06-12 09:35:44 +09:00
parent a9005e6dc4
commit a5aaff660c
7 changed files with 523 additions and 631 deletions

View File

@ -2,16 +2,8 @@
<template>
<v-app>
<!-- 로딩 오버레이 -->
<v-overlay
v-if="loading"
class="align-center justify-center"
persistent
>
<v-progress-circular
color="primary"
indeterminate
size="64"
/>
<v-overlay v-if="loading" class="align-center justify-center" persistent>
<v-progress-circular color="primary" indeterminate size="64" />
</v-overlay>
<!-- 메인 네비게이션 -->
@ -24,7 +16,7 @@
>
<v-list>
<v-list-item
prepend-avatar="/images/logo.png"
prepend-avatar="/images/logo192.png"
:title="userStore.user?.nickname || '사용자'"
:subtitle="userStore.user?.businessName || '매장명'"
/>
@ -45,13 +37,7 @@
<template v-slot:append>
<div class="pa-4">
<v-btn
block
color="primary"
variant="outlined"
prepend-icon="mdi-logout"
@click="logout"
>
<v-btn block color="primary" variant="outlined" prepend-icon="mdi-logout" @click="logout">
로그아웃
</v-btn>
</div>
@ -59,16 +45,8 @@
</v-navigation-drawer>
<!-- 상단 앱바 -->
<v-app-bar
v-if="isAuthenticated && !isLoginPage"
app
elevation="1"
color="primary"
>
<v-app-bar-nav-icon
@click="drawer = !drawer"
color="white"
/>
<v-app-bar v-if="isAuthenticated && !isLoginPage" app elevation="1" color="primary">
<v-app-bar-nav-icon @click="drawer = !drawer" color="white" />
<v-toolbar-title class="text-white font-weight-bold">
{{ currentPageTitle }}
@ -77,84 +55,31 @@
<v-spacer />
<!-- 알림 버튼 -->
<v-btn
icon
color="white"
@click="showNotifications = true"
>
<v-badge
v-if="notificationCount > 0"
:content="notificationCount"
color="error"
>
<v-btn icon color="white" @click="showNotifications = true">
<v-badge v-if="notificationCount > 0" :content="notificationCount" color="error">
<v-icon>mdi-bell</v-icon>
</v-badge>
<v-icon v-else>mdi-bell</v-icon>
</v-btn>
</v-app-bar>
<!-- 메인 텐츠 -->
<!-- 메인 컨텐츠 -->
<v-main>
<router-view />
</v-main>
<!-- 하단 네비게이션 (모바일) -->
<v-bottom-navigation
v-if="isAuthenticated && !isLoginPage && $vuetify.display.mobile"
v-model="bottomNav"
app
grow
height="70"
>
<v-btn
v-for="item in bottomMenuItems"
:key="item.route"
:to="item.route"
:value="item.route"
stacked
>
<v-icon>{{ item.icon }}</v-icon>
<span class="text-caption">{{ item.title }}</span>
</v-btn>
</v-bottom-navigation>
<!-- 알림 다이얼로그 -->
<v-dialog
v-model="showNotifications"
max-width="400"
scrollable
>
<v-dialog v-model="showNotifications" max-width="500">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
알림
<v-btn
icon
size="small"
@click="showNotifications = false"
>
<v-card-title>
<span class="text-h6">알림</span>
<v-spacer />
<v-btn icon variant="text" @click="showNotifications = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider />
<v-card-text style="max-height: 400px;">
<v-list v-if="notifications.length > 0">
<v-list-item
v-for="notification in notifications"
:key="notification.id"
:subtitle="notification.message"
:title="notification.title"
>
<template v-slot:prepend>
<v-icon :color="notification.type">
{{ getNotificationIcon(notification.type) }}
</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else class="text-center pa-4">
<v-card-text>
<div v-if="notifications.length === 0" class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2">mdi-bell-off</v-icon>
<p class="text-grey mt-2">새로운 알림이 없습니다</p>
</div>
@ -171,19 +96,14 @@
>
{{ snackbar.message }}
<template v-slot:actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
닫기
</v-btn>
<v-btn variant="text" @click="snackbar.show = false"> 닫기 </v-btn>
</template>
</v-snackbar>
</v-app>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { useAppStore } from '@/store/app'
@ -195,7 +115,6 @@ const appStore = useAppStore()
//
const drawer = ref(false)
const bottomNav = ref('')
const showNotifications = ref(false)
const loading = ref(false)
@ -203,106 +122,50 @@ const loading = ref(false)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const isLoginPage = computed(() => route.name === 'Login')
const userStore = computed(() => authStore)
const notificationCount = computed(() => appStore.notificationCount)
const notifications = computed(() => appStore.notifications)
const notificationCount = computed(() => appStore.notificationCount || 0)
const notifications = computed(() => appStore.notifications || [])
const snackbar = computed(() => appStore.snackbar)
//
const currentPageTitle = computed(() => {
const titles = {
'Dashboard': '대시보드',
'StoreManagement': '매장 관리',
'MenuManagement': '메뉴 관리',
'ContentCreation': '콘텐츠 생성',
'ContentManagement': '콘텐츠 관리',
'AIRecommendation': 'AI 추천',
'SalesAnalysis': '매출 분석'
Dashboard: '대시보드',
StoreManagement: '매장 관리',
ContentCreation: '콘텐츠 생성',
ContentManagement: '콘텐츠 관리',
}
return titles[route.name] || 'AI 마케팅'
})
//
// ( )
const menuItems = [
{ title: '대시보드', icon: 'mdi-view-dashboard', route: '/dashboard' },
{ title: '매장 관리', icon: 'mdi-store', route: '/store' },
{ title: '메뉴 관리', icon: 'mdi-food', route: '/menu' },
{ title: '콘텐츠 생성', icon: 'mdi-plus-circle', route: '/content/create' },
{ title: '콘텐츠 생성', icon: 'mdi-creation', route: '/content/create' },
{ title: '콘텐츠 관리', icon: 'mdi-folder-multiple', route: '/content' },
{ title: 'AI 추천', icon: 'mdi-robot', route: '/ai-recommend' },
{ title: '매출 분석', icon: 'mdi-chart-line', route: '/sales' }
]
const bottomMenuItems = [
{ title: '홈', icon: 'mdi-home', route: '/dashboard' },
{ title: '매장', icon: 'mdi-store', route: '/store' },
{ title: '생성', icon: 'mdi-plus-circle', route: '/content/create' },
{ title: '분석', icon: 'mdi-chart-line', route: '/sales' }
]
//
//
const logout = async () => {
try {
loading.value = true
await authStore.logout()
router.push('/login')
} catch (error) {
appStore.showSnackbar('로그아웃 중 오류가 발생했습니다', 'error')
} finally {
loading.value = false
console.error('로그아웃 실패:', error)
}
}
const getNotificationIcon = (type) => {
const icons = {
'success': 'mdi-check-circle',
'error': 'mdi-alert-circle',
'warning': 'mdi-alert',
'info': 'mdi-information'
}
return icons[type] || 'mdi-bell'
}
//
onMounted(async () => {
//
if (authStore.token) {
try {
await authStore.refreshUserInfo()
} catch (error) {
console.error('사용자 정보 갱신 실패:', error)
}
}
})
//
watch(route, (to) => {
bottomNav.value = to.path
//
onMounted(() => {
console.log('App 컴포넌트 마운트됨')
console.log('현재 라우트:', route.path)
console.log('인증 상태:', authStore.isAuthenticated)
})
</script>
<style>
/* 글로벌 스타일 */
.v-application {
font-family: 'Noto Sans KR', sans-serif !important;
}
/* 모바일 최적화 */
@media (max-width: 600px) {
.v-toolbar-title {
font-size: 1.1rem !important;
}
.v-navigation-drawer {
max-width: 280px;
}
}
/* 커스텀 스타일 */
.fade-transition {
transition: opacity 0.3s ease;
}
.slide-transition {
transition: transform 0.3s ease;
<style scoped>
.v-app-bar-title {
font-size: 1.25rem;
font-weight: 600;
}
</style>

View File

@ -40,20 +40,6 @@ const vuetify = createVuetify({
'surface-variant': '#F5F5F5',
},
},
dark: {
colors: {
primary: '#2196F3',
secondary: '#616161',
accent: '#82B1FF',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
warning: '#FFC107',
background: '#121212',
surface: '#1E1E1E',
'surface-variant': '#424242',
},
},
},
},
icons: {
@ -72,27 +58,6 @@ const vuetify = createVuetify({
xl: 1920,
},
},
defaults: {
VCard: {
elevation: 2,
rounded: 'lg',
},
VBtn: {
rounded: 'lg',
},
VTextField: {
variant: 'outlined',
density: 'comfortable',
},
VSelect: {
variant: 'outlined',
density: 'comfortable',
},
VTextarea: {
variant: 'outlined',
density: 'comfortable',
},
},
})
// Pinia 스토어 생성
@ -107,12 +72,6 @@ app.config.globalProperties.$config = window.__runtime_config__ || {}
// 에러 핸들링
app.config.errorHandler = (err, instance, info) => {
console.error('Vue 앱 에러:', err, info)
// 프로덕션 환경에서 에러 리포팅
if (import.meta.env.PROD) {
// 에러 리포팅 서비스에 전송
// reportError(err, instance, info)
}
}
// 플러그인 등록
@ -123,20 +82,7 @@ app.use(vuetify)
// 개발 모드 설정
if (import.meta.env.DEV) {
app.config.performance = true
window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || {}
}
// 앱 마운트
app.mount('#app')
// 서비스 워커 등록 (프로덕션에서만)
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('서비스 워커 등록 성공:', registration)
} catch (error) {
console.log('서비스 워커 등록 실패:', error)
}
})
}

View File

@ -4,26 +4,18 @@
* 라우팅 네비게이션 가드 설정
*/
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/store/auth'
// 뷰 컴포넌트 lazy loading
const LoginView = () => import('@/views/LoginView.vue')
const DashboardView = () => import('@/views/DashboardView.vue')
const StoreManagementView = () => import('@/views/StoreManagementView.vue')
const MenuManagementView = () => import('@/views/MenuManagementView.vue')
const ContentCreationView = () => import('@/views/ContentCreationView.vue')
const ContentManagementView = () => import('@/views/ContentManagementView.vue')
const AIRecommendationView = () => import('@/views/AIRecommendationView.vue')
const SalesAnalysisView = () => import('@/views/SalesAnalysisView.vue')
const routes = [
{
path: '/',
redirect: (to) => {
// 인증 상태에 따른 동적 리다이렉트
const authStore = useAuthStore()
return authStore.isAuthenticated ? '/dashboard' : '/login'
},
redirect: '/login', // 항상 로그인 페이지로 먼저 리다이렉트
},
{
path: '/login',
@ -52,15 +44,6 @@ const routes = [
title: '매장 관리',
},
},
{
path: '/menu',
name: 'MenuManagement',
component: MenuManagementView,
meta: {
requiresAuth: true,
title: '메뉴 관리',
},
},
{
path: '/content/create',
name: 'ContentCreation',
@ -79,24 +62,6 @@ const routes = [
title: '콘텐츠 관리',
},
},
{
path: '/ai-recommend',
name: 'AIRecommendation',
component: AIRecommendationView,
meta: {
requiresAuth: true,
title: 'AI 추천',
},
},
{
path: '/sales',
name: 'SalesAnalysis',
component: SalesAnalysisView,
meta: {
requiresAuth: true,
title: '매출 분석',
},
},
{
path: '/:pathMatch(.*)*',
redirect: '/login', // 404시 로그인으로 이동
@ -108,41 +73,40 @@ const router = createRouter({
routes,
})
// 네비게이션 가드
// 네비게이션 가드 - 수정된 버전
router.beforeEach(async (to, from, next) => {
console.log('=== 라우터 가드 실행 ===')
console.log('이동 경로:', `${from.path}${to.path}`)
// Pinia 스토어를 동적으로 가져오기 (순환 참조 방지)
const { useAuthStore } = await import('@/store/auth')
const authStore = useAuthStore()
// 인증 상태 확인
authStore.checkAuthState()
console.log('현재 인증 상태:', authStore.isAuthenticated)
console.log('토큰 존재:', !!authStore.token)
console.log('사용자 정보:', authStore.user)
console.log('사용자 정보:', authStore.user?.nickname)
// 인증이 필요한 페이지인지 확인
if (to.meta.requiresAuth) {
console.log('인증이 필요한 페이지')
const requiresAuth = to.meta.requiresAuth !== false
if (!authStore.isAuthenticated) {
console.log('인증되지 않음, 로그인으로 이동')
if (requiresAuth && !authStore.isAuthenticated) {
console.log('인증 필요 - 로그인 페이지로 이동')
next('/login')
} else {
console.log('인증됨, 페이지 이동 허용')
next()
}
} else {
console.log('인증이 필요하지 않은 페이지')
// 로그인 페이지에 이미 인증된 사용자가 접근하는 경우
if (to.name === 'Login' && authStore.isAuthenticated) {
console.log('이미 로그인됨, 대시보드로 리다이렉트')
} else if (to.path === '/login' && authStore.isAuthenticated) {
console.log('이미 로그인됨 - 대시보드로 이동')
next('/dashboard')
} else {
console.log('페이지 이동 허용')
console.log('이동 허용:', to.path)
next()
}
}
})
console.log('=== 라우터 가드 종료 ===')
router.afterEach((to) => {
// 페이지 타이틀 설정
document.title = to.meta.title ? `${to.meta.title} - AI 마케팅` : 'AI 마케팅'
})
export default router

View File

@ -2,116 +2,154 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
/**
* 인증 관리 스토어
* 로그인/로그아웃 상태 관리
*/
export const useAuthStore = defineStore('auth', () => {
// State
// 상태
const user = ref(null)
const token = ref(localStorage.getItem('auth_token') || null)
const token = ref(null)
const refreshToken = ref(null)
const isLoading = ref(false)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userInfo = computed(() => user.value)
// 컴퓨티드 - 더 엄격한 인증 체크
const isAuthenticated = computed(() => {
return !!(token.value && user.value)
})
// Actions
// 액션
const login = async (credentials) => {
isLoading.value = true
try {
console.log('Auth Store - 로그인 시도:', credentials)
console.log('=== AUTH STORE 로그인 시작 ===')
console.log('받은 자격증명:', credentials)
// 임시 로그인 로직 (실제 API 연동 전)
if (credentials.userId === 'user01' && credentials.password === 'passw0rd') {
const userData = {
isLoading.value = true
try {
// 실제 API 호출 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1000))
// 정확한 자격증명 확인 (대소문자 구분, 공백 제거)
const username = credentials.username?.trim()
const password = credentials.password?.trim()
console.log('정제된 자격증명:', { username, password })
console.log('예상 자격증명:', { username: 'user01', password: 'passw0rd' })
console.log('username 일치:', username === 'user01')
console.log('password 일치:', password === 'passw0rd')
if (username === 'user01' && password === 'passw0rd') {
const mockToken = 'mock_jwt_token_' + Date.now()
const mockUser = {
id: 1,
userId: credentials.userId,
name: '테스트 사용자',
email: 'test@example.com',
username: username,
nickname: '김사장',
businessName: '김사장님의 분식점',
email: 'kim@example.com',
avatar: '/images/avatar.png',
}
const tokenData = 'temp_jwt_token_' + Date.now()
// 상태 업데이트
token.value = tokenData
user.value = userData
// 토큰과 사용자 정보 저장
token.value = mockToken
user.value = mockUser
// 로컬 스토리지에 저장
localStorage.setItem('auth_token', tokenData)
localStorage.setItem('user_info', JSON.stringify(userData))
localStorage.setItem('auth_token', mockToken)
localStorage.setItem('user_info', JSON.stringify(mockUser))
console.log('Auth Store - 로그인 성공:', { token: tokenData, user: userData })
console.log('Auth Store - isAuthenticated:', isAuthenticated.value)
return { success: true, user: userData, token: tokenData }
console.log('로그인 성공! 사용자:', mockUser.nickname)
return { success: true }
} else {
return { success: false, message: '아이디 또는 비밀번호가 틀렸습니다' }
console.log('로그인 실패 - 자격증명 불일치')
throw new Error('아이디 또는 비밀번호가 올바르지 않습니다.')
}
} catch (error) {
console.error('Auth Store - 로그인 에러:', error)
return { success: false, message: '로그인 중 오류가 발생했습니다' }
console.error('로그인 실패:', error)
return { success: false, error: error.message }
} finally {
isLoading.value = false
}
}
const logout = () => {
console.log('Auth Store - 로그아웃')
token.value = null
user.value = null
localStorage.removeItem('auth_token')
localStorage.removeItem('user_info')
}
const clearAuth = () => {
console.log('Auth Store - 인증 정보 클리어')
logout()
}
const checkAuth = () => {
console.log('Auth Store - 인증 상태 체크')
const storedToken = localStorage.getItem('auth_token')
const storedUser = localStorage.getItem('user_info')
if (storedToken && storedUser) {
const logout = async () => {
try {
token.value = storedToken
user.value = JSON.parse(storedUser)
console.log('Auth Store - 저장된 인증 정보 복원:', { token: storedToken, user: user.value })
// 로컬 상태 초기화
user.value = null
token.value = null
refreshToken.value = null
// 로컬 스토리지 정리
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
console.log('로그아웃 완료')
return { success: true }
} catch (error) {
console.error('Auth Store - 인증 정보 복원 실패:', error)
clearAuth()
console.error('로그아웃 실패:', error)
return { success: false, error: error.message }
}
}
const checkAuthState = () => {
try {
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('user_info')
if (savedToken && savedUser) {
// JSON 파싱이 가능한지 확인
const parsedUser = JSON.parse(savedUser)
if (parsedUser && parsedUser.id) {
token.value = savedToken
user.value = parsedUser
console.log('저장된 인증 정보 복원됨:', parsedUser.nickname)
} else {
// 잘못된 사용자 정보인 경우 정리
clearAuthData()
}
} else {
console.log('저장된 인증 정보가 없음')
}
} catch (error) {
console.error('인증 상태 확인 실패:', error)
clearAuthData()
}
}
const clearAuthData = () => {
user.value = null
token.value = null
refreshToken.value = null
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user_info')
console.log('인증 데이터 초기화됨')
}
const refreshUserInfo = async () => {
console.log('Auth Store - 사용자 정보 갱신')
// 임시로 현재 사용자 정보 반환
if (token.value) {
return Promise.resolve(user.value)
} else {
throw new Error('토큰이 없습니다')
}
}
if (!token.value) return
// 초기 인증 상태 체크
checkAuth()
try {
// 실제 API 호출로 사용자 정보 갱신
// const response = await api.get('/auth/me')
// user.value = response.data
} catch (error) {
console.error('사용자 정보 갱신 실패:', error)
// 토큰이 유효하지 않은 경우 로그아웃
logout()
}
}
return {
// State
// 상태
user,
token,
isLoading,
// Getters
// 컴퓨티드
isAuthenticated,
userInfo,
// Actions
// 액션
login,
logout,
clearAuth,
checkAuth,
refreshUserInfo,
checkAuthState,
clearAuthData,
}
})

95
src/styles/variables.scss Normal file
View File

@ -0,0 +1,95 @@
/**
* Sass 변수 정의
* 전역에서 사용할 색상, 크기, 간격 등의 변수들
*/
// 색상 변수
$primary-color: #1976d2;
$secondary-color: #424242;
$accent-color: #82b1ff;
$error-color: #ff5252;
$info-color: #2196f3;
$success-color: #4caf50;
$warning-color: #ffc107;
// 배경색
$background-color: #ffffff;
$surface-color: #ffffff;
$surface-variant-color: #f5f5f5;
// 텍스트 색상
$text-primary: #212121;
$text-secondary: #757575;
$text-disabled: #bdbdbd;
// 테두리 구분선
$border-color: #e0e0e0;
$divider-color: #f5f5f5;
// 그림자
$shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.1);
$shadow-md: 0 4px 8px rgba(0, 0, 0, 0.1);
$shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.1);
// 테두리 반경
$border-radius-sm: 4px;
$border-radius-md: 8px;
$border-radius-lg: 12px;
$border-radius-xl: 16px;
// 간격
$space-1: 4px;
$space-2: 8px;
$space-3: 12px;
$space-4: 16px;
$space-5: 20px;
$space-6: 24px;
$space-8: 32px;
$space-10: 40px;
$space-12: 48px;
$space-16: 64px;
// 폰트 크기
$font-size-xs: 0.75rem; // 12px
$font-size-sm: 0.875rem; // 14px
$font-size-base: 1rem; // 16px
$font-size-lg: 1.125rem; // 18px
$font-size-xl: 1.25rem; // 20px
$font-size-2xl: 1.5rem; // 24px
$font-size-3xl: 1.875rem; // 30px
$font-size-4xl: 2.25rem; // 36px
// 폰트 두께
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$font-weight-bold: 700;
// 전환 효과
$transition-fast: 0.15s ease;
$transition-normal: 0.3s ease;
$transition-slow: 0.5s ease;
// 브레이크포인트
$breakpoint-xs: 0;
$breakpoint-sm: 600px;
$breakpoint-md: 960px;
$breakpoint-lg: 1280px;
$breakpoint-xl: 1920px;
// Z-인덱스
$z-index-dropdown: 1000;
$z-index-modal: 1050;
$z-index-tooltip: 1100;
$z-index-toast: 1200;
// 다크 테마 색상
$dark-background-color: #121212;
$dark-surface-color: #1e1e1e;
$dark-surface-variant-color: #424242;
$dark-text-primary: #ffffff;
$dark-text-secondary: #aaaaaa;
$dark-text-disabled: #666666;
$dark-border-color: #333333;
$dark-divider-color: #2a2a2a;

View File

@ -1,171 +1,179 @@
//* src/views/LoginView.vue
<template>
<v-container fluid class="fill-height pa-0">
<v-row no-gutters class="fill-height">
<v-col cols="12" class="d-flex flex-column">
<!-- 로고 헤더 -->
<div class="text-center pa-8 bg-primary">
<v-img src="/images/logo.png" alt="AI 마케팅 로고" max-width="80" class="mx-auto mb-4" />
<h1 class="text-h4 text-white font-weight-bold mb-2">AI 마케팅</h1>
<p class="text-white opacity-90">소상공인을 위한 똑똑한 마케팅 솔루션</p>
<v-container fluid class="login-container">
<v-row justify="center" align="center" class="fill-height">
<v-col cols="12" sm="8" md="6" lg="4" xl="3">
<v-card class="login-card" elevation="8">
<!-- 로고 섹션 -->
<v-card-text class="text-center pa-8">
<div class="logo-section mb-6">
<v-img
src="/images/logo192.png"
alt="AI 마케팅 로고"
width="80"
height="80"
class="mx-auto mb-4"
/>
<h1 class="text-h4 font-weight-bold primary--text mb-2">AI 마케팅</h1>
<p class="text-subtitle-1 text-grey-darken-1">소상공인을 위한 스마트 마케팅 솔루션</p>
</div>
<!-- 로그인 -->
<div class="flex-grow-1 pa-6">
<v-card class="mx-auto" max-width="400" elevation="8">
<v-card-title class="text-h5 text-center pa-6"> 로그인 </v-card-title>
<v-card-text class="pa-6">
<v-form ref="loginForm" v-model="formValid" @submit.prevent="handleLogin">
<v-form ref="loginForm" v-model="isFormValid" @submit.prevent="handleLogin">
<v-text-field
v-model="loginData.userId"
v-model="credentials.username"
label="아이디"
prepend-inner-icon="mdi-account"
variant="outlined"
:rules="userIdRules"
required
:rules="usernameRules"
:error-messages="fieldErrors.username"
class="mb-4"
autocomplete="username"
@keyup.enter="handleLogin"
/>
<v-text-field
v-model="loginData.password"
v-model="credentials.password"
label="비밀번호"
prepend-inner-icon="mdi-lock"
variant="outlined"
:type="showPassword ? 'text' : 'password'"
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
variant="outlined"
:rules="passwordRules"
required
:error-messages="fieldErrors.password"
class="mb-4"
autocomplete="current-password"
@click:append-inner="showPassword = !showPassword"
@keyup.enter="handleLogin"
/>
<!-- 로그인 옵션 -->
<div class="d-flex justify-space-between align-center mb-6">
<v-checkbox
v-model="rememberMe"
label="로그인 상태 유지"
density="compact"
hide-details
/>
<v-btn
variant="text"
size="small"
color="primary"
@click="showForgotPassword = true"
>
비밀번호 찾기
</v-btn>
</div>
<!-- 에러 메시지 -->
<v-alert
v-if="loginError"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="loginError = ''"
>
{{ loginError }}
</v-alert>
<!-- 디버그 정보 (개발 중에만 표시) -->
<v-card v-if="showDebugInfo" variant="outlined" class="mb-4 pa-3">
<div class="text-caption">디버그 정보:</div>
<div class="text-caption">아이디: "{{ credentials.username }}"</div>
<div class="text-caption">비밀번호: "{{ credentials.password }}"</div>
<div class="text-caption"> 유효성: {{ isFormValid }}</div>
<div class="text-caption">로딩 상태: {{ authStore.isLoading }}</div>
</v-card>
<!-- 로그인 버튼 -->
<v-btn
type="submit"
block
size="large"
color="primary"
:loading="loading"
:disabled="!formValid"
size="large"
block
:loading="authStore.isLoading"
:disabled="!isFormValid || authStore.isLoading"
class="mb-4"
>
<v-icon start>mdi-login</v-icon>
로그인
</v-btn>
<!-- 테스트용 기본값 설정 버튼 -->
<v-btn
variant="outlined"
block
color="secondary"
@click="setTestCredentials"
class="mb-4"
>
테스트 계정으로 입력
</v-btn>
<!-- 회원가입 링크 -->
<div class="text-center">
<v-btn variant="text" color="primary" @click="showRegisterDialog = true">
<span class="text-body-2 text-grey-darken-1"> 계정이 없으신가요? </span>
<v-btn variant="text" color="primary" size="small" @click="showSignup = true">
회원가입
</v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
</div>
<!-- 푸터 -->
<div class="text-center pa-4 text-caption text-grey">
© 2024 AI Marketing Service. All rights reserved.
<!-- 데모 정보 카드 -->
<v-card class="mt-4" variant="outlined">
<v-card-text class="pa-4">
<div class="text-center">
<v-icon color="info" class="mb-2">mdi-information</v-icon>
<h3 class="text-subtitle-2 font-weight-bold mb-2">데모 계정 정보</h3>
<div class="demo-info">
<div class="text-body-2 mb-1">
<span class="font-weight-medium">아이디:</span> user01
</div>
<div class="text-body-2 mb-2">
<span class="font-weight-medium">비밀번호:</span> passw0rd
</div>
<v-btn size="small" color="info" variant="outlined" @click="fillDemoCredentials">
데모 계정 자동 입력
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 회원가입 다이얼로그 -->
<v-dialog v-model="showRegisterDialog" max-width="500" scrollable>
<!-- 비밀번호 찾기 다이얼로그 -->
<v-dialog v-model="showForgotPassword" max-width="400">
<v-card>
<v-card-title class="text-h6">
회원가입
<v-spacer />
<v-btn icon @click="showRegisterDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form ref="registerForm" v-model="registerFormValid">
<v-card-title>비밀번호 찾기</v-card-title>
<v-card-text>
<p class="mb-4">등록하신 이메일 주소를 입력해주세요.</p>
<v-text-field
v-model="registerData.userId"
label="아이디"
variant="outlined"
:rules="userIdRules"
required
class="mb-4"
/>
<v-text-field
v-model="registerData.password"
label="비밀번호"
variant="outlined"
type="password"
:rules="registerPasswordRules"
required
class="mb-4"
/>
<v-text-field
v-model="registerData.confirmPassword"
label="비밀번호 확인"
variant="outlined"
type="password"
:rules="confirmPasswordRules"
required
class="mb-4"
/>
<v-text-field
v-model="registerData.nickname"
label="닉네임"
variant="outlined"
:rules="nicknameRules"
required
class="mb-4"
/>
<v-text-field
v-model="registerData.businessNumber"
label="사업자등록번호"
variant="outlined"
:rules="businessNumberRules"
required
class="mb-4"
/>
<v-text-field
v-model="registerData.email"
v-model="forgotEmail"
label="이메일"
variant="outlined"
type="email"
:rules="emailRules"
required
variant="outlined"
prepend-inner-icon="mdi-email"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showForgotPassword = false">취소</v-btn>
<v-btn color="primary" @click="handleForgotPassword">전송</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 회원가입 다이얼로그 -->
<v-dialog v-model="showSignup" max-width="500">
<v-card>
<v-card-title>회원가입</v-card-title>
<v-card-text>
<p class="mb-4">AI 마케팅 서비스에 오신 것을 환영합니다!</p>
<v-form>
<v-text-field label="아이디" variant="outlined" class="mb-2" />
<v-text-field label="비밀번호" type="password" variant="outlined" class="mb-2" />
<v-text-field label="비밀번호 확인" type="password" variant="outlined" class="mb-2" />
<v-text-field label="이메일" type="email" variant="outlined" class="mb-2" />
<v-text-field label="매장명" variant="outlined" class="mb-2" />
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-6">
<v-card-actions>
<v-spacer />
<v-btn color="grey" variant="text" @click="showRegisterDialog = false"> 취소 </v-btn>
<v-btn
color="primary"
:loading="registerLoading"
:disabled="!registerFormValid"
@click="register"
>
가입하기
</v-btn>
<v-btn variant="text" @click="showSignup = false">취소</v-btn>
<v-btn color="primary" @click="handleSignup">가입하기</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@ -173,182 +181,168 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth' // import
import { useAppStore } from '@/store/app' // App Store
/**
* 로그인 페이지
* 사용자 인증 회원가입 기능 제공
*/
import { useAuthStore } from '@/store/auth'
import { useAppStore } from '@/store/app'
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore() // App Store
const appStore = useAppStore()
//
const formValid = ref(false)
const registerFormValid = ref(false)
const loading = ref(false)
const registerLoading = ref(false)
const loginForm = ref(null)
const isFormValid = ref(false)
const showPassword = ref(false)
const showRegisterDialog = ref(false)
const rememberMe = ref(false)
const loginError = ref('')
const showForgotPassword = ref(false)
const showSignup = ref(false)
const forgotEmail = ref('')
const showDebugInfo = ref(true) // true
const loginData = reactive({
userId: 'user01', //
password: 'passw0rd', //
// -
const credentials = ref({
username: 'user01', //
password: 'passw0rd', //
})
const registerData = reactive({
userId: '',
password: '',
confirmPassword: '',
nickname: '',
businessNumber: '',
email: '',
const fieldErrors = ref({
username: [],
password: [],
})
// ( )
const userIdRules = [(v) => !!v || '아이디를 입력해주세요']
//
const usernameRules = [
(v) => !!v || '아이디를 입력해주세요',
(v) => (v && v.length >= 3) || '아이디는 3자 이상이어야 합니다',
]
const passwordRules = [(v) => !!v || '비밀번호를 입력해주세요']
//
const registerPasswordRules = [
const passwordRules = [
(v) => !!v || '비밀번호를 입력해주세요',
(v) => v.length >= 8 || '비밀번호는 8자 이상이어야 합니다',
(v) =>
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/.test(v) ||
'영문, 숫자, 특수문자를 포함해야 합니다',
]
const confirmPasswordRules = [
(v) => !!v || '비밀번호 확인을 입력해주세요',
(v) => v === registerData.password || '비밀번호가 일치하지 않습니다',
]
const nicknameRules = [
(v) => !!v || '닉네임을 입력해주세요',
(v) => v.length >= 2 || '닉네임은 2자 이상이어야 합니다',
]
const businessNumberRules = [(v) => !!v || '사업자등록번호를 입력해주세요']
const emailRules = [
(v) => !!v || '이메일을 입력해주세요',
(v) => /.+@.+\..+/.test(v) || ' ',
(v) => (v && v.length >= 6) || '비밀번호는 6자 이상이어야 합니다',
]
//
const handleLogin = async () => {
console.log('=== 로그인 처리 시작 ===')
console.log('입력 데이터:', loginData)
const fillDemoCredentials = () => {
credentials.value.username = 'user01'
credentials.value.password = 'passw0rd'
loginError.value = ''
console.log('데모 계정 정보 자동 입력 완료')
}
if (!formValid.value) {
const handleLogin = async () => {
console.log('=== 로그인 시도 시작 ===')
console.log('폼 유효성:', isFormValid.value)
console.log('입력된 자격증명:', {
username: credentials.value.username,
password: credentials.value.password,
usernameLength: credentials.value.username?.length,
passwordLength: credentials.value.password?.length,
})
//
if (!isFormValid.value) {
console.log('폼 유효성 검사 실패')
return
}
loading.value = true
//
if (!credentials.value.username || !credentials.value.password) {
loginError.value = '아이디와 비밀번호를 모두 입력해주세요'
return
}
//
loginError.value = ''
fieldErrors.value = { username: [], password: [] }
try {
// Auth Store login ( )
console.log('auth store 로그인 호출 중...')
const result = await authStore.login({
userId: loginData.userId,
password: loginData.password,
username: credentials.value.username.trim(), //
password: credentials.value.password.trim(), //
})
console.log('로그인 결과:', result)
if (result.success) {
console.log('로그인 성공!')
appStore.showSnackbar('로그인되었습니다', 'success')
console.log('로그인 성공, 인증 상태 확인:', authStore.isAuthenticated)
//
console.log('대시보드로 이동 시도')
await router.push('/dashboard')
console.log('라우터 이동 완료')
router.push('/dashboard')
} else {
appStore.showSnackbar(result.message || '로그인에 실패했습니다', 'error')
console.log('로그인 실패:', result.error)
loginError.value = result.error || '로그인에 실패했습니다'
}
} catch (error) {
console.error('로그인 에러:', error)
appStore.showSnackbar('로그인 중 오류가 발생했습니다', 'error')
} finally {
loading.value = false
loginError.value = '서버 오류가 발생했습니다'
}
}
console.log('=== 로그인 처리 종료 ===')
const handleForgotPassword = () => {
appStore.showSnackbar('비밀번호 재설정 이메일을 발송했습니다', 'info')
showForgotPassword.value = false
forgotEmail.value = ''
}
const setTestCredentials = () => {
loginData.userId = 'user01'
loginData.password = 'passw0rd'
appStore.showSnackbar('테스트 계정 정보가 입력되었습니다', 'info')
}
const register = async () => {
if (!registerFormValid.value) return
try {
registerLoading.value = true
// ( API )
console.log('회원가입 데이터:', registerData)
// :
appStore.showSnackbar('회원가입이 완료되었습니다', 'success')
showRegisterDialog.value = false
//
Object.assign(registerData, {
userId: '',
password: '',
confirmPassword: '',
nickname: '',
businessNumber: '',
email: '',
})
} catch (error) {
console.error('회원가입 에러:', error)
appStore.showSnackbar('회원가입에 실패했습니다', 'error')
} finally {
registerLoading.value = false
}
const handleSignup = () => {
appStore.showSnackbar('회원가입 기능은 곧 제공될 예정입니다', 'info')
showSignup.value = false
}
//
onMounted(() => {
console.log('LoginView 마운트됨')
console.log('초기 인증 상태:', authStore.isAuthenticated)
console.log('초기 자격증명:', credentials.value)
//
//
if (authStore.isAuthenticated) {
console.log('이미 로그인됨, 대시보드로 이동')
console.log('이미 로그인된 상태 - 대시보드로 이동')
router.push('/dashboard')
}
// - 3
setTimeout(() => {
showDebugInfo.value = false
}, 10000)
})
</script>
<style scoped>
.fill-height {
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
}
.bg-primary {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
.login-card {
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.95);
}
.logo-section {
padding: 20px 0;
}
.demo-info {
background: #f5f5f5;
padding: 12px;
border-radius: 8px;
margin-top: 8px;
}
@media (max-width: 600px) {
.pa-8 {
padding: 2rem !important;
.login-card {
margin: 16px;
}
.text-h4 {
font-size: 1.8rem !important;
.logo-section {
padding: 16px 0;
}
}
</style>

View File

@ -1,4 +1,3 @@
//* vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
@ -28,11 +27,4 @@ export default defineConfig({
},
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`,
},
},
},
})