files add

This commit is contained in:
unknown 2025-06-11 14:29:34 +09:00
parent 93e27a239a
commit 2e28c5a9df
6 changed files with 809 additions and 53 deletions

View File

@ -1,29 +1,26 @@
{ {
"name": "smarketing-frontend", "name": "ai-marketing-frontend",
"version": "0.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
"type-check": "vue-tsc --build",
"format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13" "vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"vuetify": "^3.5.0",
"@mdi/font": "^7.4.47",
"axios": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.1", "@vitejs/plugin-vue": "^5.0.0",
"@types/node": "^22.14.0", "vite": "^5.0.0",
"@vitejs/plugin-vue": "^5.2.3", "vite-plugin-vuetify": "^2.0.0",
"@vue/tsconfig": "^0.7.0", "sass": "^1.69.0"
"npm-run-all2": "^7.0.2",
"prettier": "3.5.3",
"typescript": "~5.8.0",
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.8"
} }
} }

View File

@ -1,47 +1,308 @@
<script setup lang="ts"> //* src/App.vue
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
</script>
<template> <template>
<header> <v-app>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" /> <!-- 로딩 오버레이 -->
<v-overlay
v-if="loading"
class="align-center justify-center"
persistent
>
<v-progress-circular
color="primary"
indeterminate
size="64"
/>
</v-overlay>
<div class="wrapper"> <!-- 메인 네비게이션 -->
<HelloWorld msg="You did it!" /> <v-navigation-drawer
v-if="isAuthenticated && !isLoginPage"
v-model="drawer"
app
temporary
width="280"
>
<v-list>
<v-list-item
prepend-avatar="/images/logo.png"
:title="userStore.user?.nickname || '사용자'"
:subtitle="userStore.user?.businessName || '매장명'"
/>
</v-list>
<v-divider />
<v-list nav density="compact">
<v-list-item
v-for="item in menuItems"
:key="item.route"
:to="item.route"
:prepend-icon="item.icon"
:title="item.title"
exact
/>
</v-list>
<template v-slot:append>
<div class="pa-4">
<v-btn
block
color="primary"
variant="outlined"
prepend-icon="mdi-logout"
@click="logout"
>
로그아웃
</v-btn>
</div> </div>
</header> </template>
</v-navigation-drawer>
<main> <!-- 상단 앱바 -->
<TheWelcome /> <v-app-bar
</main> 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 }}
</v-toolbar-title>
<v-spacer />
<!-- 알림 버튼 -->
<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-card>
<v-card-title class="d-flex justify-space-between align-center">
알림
<v-btn
icon
size="small"
@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-icon size="48" color="grey-lighten-2">mdi-bell-off</v-icon>
<p class="text-grey mt-2">새로운 알림이 없습니다</p>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- 글로벌 스낵바 -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="snackbar.timeout"
location="top"
>
{{ snackbar.message }}
<template v-slot:actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
닫기
</v-btn>
</template>
</v-snackbar>
</v-app>
</template> </template>
<style scoped> <script setup>
header { import { ref, computed, onMounted, watch } from 'vue'
line-height: 1.5; import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'
import { useAppStore } from '@/store/app'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
//
const drawer = ref(false)
const bottomNav = ref('')
const showNotifications = ref(false)
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 snackbar = computed(() => appStore.snackbar)
//
const currentPageTitle = computed(() => {
const titles = {
'Dashboard': '대시보드',
'StoreManagement': '매장 관리',
'MenuManagement': '메뉴 관리',
'ContentCreation': '콘텐츠 생성',
'ContentManagement': '콘텐츠 관리',
'AIRecommendation': 'AI 추천',
'SalesAnalysis': '매출 분석'
}
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-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
}
} }
.logo { const getNotificationIcon = (type) => {
display: block; const icons = {
margin: 0 auto 2rem; 'success': 'mdi-check-circle',
'error': 'mdi-alert-circle',
'warning': 'mdi-alert',
'info': 'mdi-information'
}
return icons[type] || 'mdi-bell'
} }
@media (min-width: 1024px) { //
header { onMounted(async () => {
display: flex; //
place-items: center; if (authStore.token) {
padding-right: calc(var(--section-gap) / 2); try {
await authStore.refreshUserInfo()
} catch (error) {
console.error('사용자 정보 갱신 실패:', error)
}
}
})
//
watch(route, (to) => {
bottomNav.value = to.path
})
</script>
<style>
/* 글로벌 스타일 */
.v-application {
font-family: 'Noto Sans KR', sans-serif !important;
}
/* 모바일 최적화 */
@media (max-width: 600px) {
.v-toolbar-title {
font-size: 1.1rem !important;
} }
.logo { .v-navigation-drawer {
margin: 0 2rem 0 0; max-width: 280px;
} }
}
header .wrapper { /* 커스텀 스타일 */
display: flex; .fade-transition {
place-items: flex-start; transition: opacity 0.3s ease;
flex-wrap: wrap; }
}
.slide-transition {
transition: transform 0.3s ease;
} }
</style> </style>

65
src/main.js Normal file
View File

@ -0,0 +1,65 @@
//* src/main.js
/**
* AI 마케팅 서비스 - 메인 진입점
* Vue 3 + Vuetify 3 기반 애플리케이션 초기화
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// Vuetify
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
// Vuetify 테마 설정
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'light',
themes: {
light: {
colors: {
primary: '#1976D2',
secondary: '#424242',
accent: '#82B1FF',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
warning: '#FFC107',
background: '#F5F5F5',
surface: '#FFFFFF'
}
}
}
},
icons: {
defaultSet: 'mdi',
sets: {
mdi
}
},
display: {
mobileBreakpoint: 'sm',
thresholds: {
xs: 0,
sm: 600,
md: 960,
lg: 1280,
xl: 1920
}
}
})
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(vuetify)
app.mount('#app')

139
src/router/index.js Normal file
View File

@ -0,0 +1,139 @@
//* src/router/index.js
/**
* Vue Router 설정
* 라우팅 네비게이션 가드 설정
*/
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: '/dashboard'
},
{
path: '/login',
name: 'Login',
component: LoginView,
meta: {
requiresAuth: false,
title: '로그인'
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: DashboardView,
meta: {
requiresAuth: true,
title: '대시보드'
}
},
{
path: '/store',
name: 'StoreManagement',
component: StoreManagementView,
meta: {
requiresAuth: true,
title: '매장 관리'
}
},
{
path: '/menu',
name: 'MenuManagement',
component: MenuManagementView,
meta: {
requiresAuth: true,
title: '메뉴 관리'
}
},
{
path: '/content/create',
name: 'ContentCreation',
component: ContentCreationView,
meta: {
requiresAuth: true,
title: '콘텐츠 생성'
}
},
{
path: '/content',
name: 'ContentManagement',
component: ContentManagementView,
meta: {
requiresAuth: true,
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: '/dashboard'
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
// 네비게이션 가드
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// 인증이 필요한 페이지인지 확인
if (to.meta.requiresAuth) {
if (!authStore.isAuthenticated) {
// 토큰이 있다면 사용자 정보 재검증
if (authStore.token) {
try {
await authStore.refreshUserInfo()
next()
} catch (error) {
authStore.clearAuth()
next('/login')
}
} else {
next('/login')
}
} else {
next()
}
} else {
// 로그인 페이지에 이미 인증된 사용자가 접근하는 경우
if (to.name === 'Login' && authStore.isAuthenticated) {
next('/dashboard')
} else {
next()
}
}
})
export default router

268
src/store/index.js Normal file
View File

@ -0,0 +1,268 @@
//* src/store/index.js
/**
* Pinia 스토어 설정
* 전역 상태 관리
*/
import { defineStore } from 'pinia'
import authService from '@/services/auth'
import storeService from '@/services/store'
// 인증 스토어
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken'),
isAuthenticated: false
}),
getters: {
getUserInfo: (state) => state.user,
isLoggedIn: (state) => state.isAuthenticated && !!state.token
},
actions: {
async login(credentials) {
try {
const response = await authService.login(credentials)
this.setAuth(response.data)
return response
} catch (error) {
this.clearAuth()
throw error
}
},
async register(userData) {
try {
const response = await authService.register(userData)
return response
} catch (error) {
throw error
}
},
async logout() {
try {
if (this.token) {
await authService.logout()
}
} catch (error) {
console.error('로그아웃 오류:', error)
} finally {
this.clearAuth()
}
},
async refreshUserInfo() {
try {
const response = await authService.getUserInfo()
this.user = response.data
this.isAuthenticated = true
return response
} catch (error) {
this.clearAuth()
throw error
}
},
setAuth(authData) {
this.user = authData.user
this.token = authData.accessToken
this.refreshToken = authData.refreshToken
this.isAuthenticated = true
localStorage.setItem('token', authData.accessToken)
localStorage.setItem('refreshToken', authData.refreshToken)
},
clearAuth() {
this.user = null
this.token = null
this.refreshToken = null
this.isAuthenticated = false
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
}
}
})
// 앱 전역 스토어
export const useAppStore = defineStore('app', {
state: () => ({
loading: false,
snackbar: {
show: false,
message: '',
color: 'success',
timeout: 3000
},
notifications: [],
notificationCount: 0
}),
actions: {
setLoading(status) {
this.loading = status
},
showSnackbar(message, color = 'success', timeout = 3000) {
this.snackbar = {
show: true,
message,
color,
timeout
}
},
hideSnackbar() {
this.snackbar.show = false
},
addNotification(notification) {
this.notifications.unshift({
id: Date.now(),
timestamp: new Date(),
...notification
})
this.notificationCount = this.notifications.length
},
clearNotifications() {
this.notifications = []
this.notificationCount = 0
}
}
})
// 매장 스토어
export const useStoreStore = defineStore('store', {
state: () => ({
storeInfo: null,
loading: false
}),
getters: {
hasStoreInfo: (state) => !!state.storeInfo
},
actions: {
async fetchStoreInfo() {
try {
this.loading = true
const response = await storeService.getStoreInfo()
this.storeInfo = response.data
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
async updateStoreInfo(storeData) {
try {
this.loading = true
const response = await storeService.updateStoreInfo(storeData)
this.storeInfo = response.data
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
async createStoreInfo(storeData) {
try {
this.loading = true
const response = await storeService.createStoreInfo(storeData)
this.storeInfo = response.data
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
}
}
})
// 메뉴 스토어
export const useMenuStore = defineStore('menu', {
state: () => ({
menus: [],
loading: false,
totalCount: 0
}),
getters: {
getMenuById: (state) => (id) => {
return state.menus.find(menu => menu.id === id)
},
getMenusByCategory: (state) => (category) => {
return state.menus.filter(menu => menu.category === category)
}
},
actions: {
async fetchMenus() {
try {
this.loading = true
const response = await storeService.getMenus()
this.menus = response.data
this.totalCount = response.data.length
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
async createMenu(menuData) {
try {
this.loading = true
const response = await storeService.createMenu(menuData)
this.menus.push(response.data)
this.totalCount++
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
async updateMenu(menuId, menuData) {
try {
this.loading = true
const response = await storeService.updateMenu(menuId, menuData)
const index = this.menus.findIndex(menu => menu.id === menuId)
if (index !== -1) {
this.menus[index] = response.data
}
return response
} catch (error) {
throw error
} finally {
this.loading = false
}
},
async deleteMenu(menuId) {
try {
this.loading = true
await storeService.deleteMenu(menuId)
this.menus = this.menus.filter(menu => menu.id !== menuId)
this.totalCount--
} catch (error) {
throw error
} finally {
this.loading = false
}
}
}
})

26
vite.config.js Normal file
View File

@ -0,0 +1,26 @@
//* vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
vuetify({
autoImport: true,
theme: {
defaultTheme: 'light'
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3000,
host: true
}
})