This commit is contained in:
SeoJHeasdw 2025-06-11 15:12:49 +09:00
parent 43066ca623
commit 5f963a9cd9
9 changed files with 3265 additions and 0 deletions

View File

@ -0,0 +1,228 @@
//* src/components/common/AppHeader.vue
<template>
<v-app-bar :color="color" :elevation="elevation" :height="height" app>
<!-- 뒤로가기 버튼 -->
<v-btn v-if="showBackButton" icon @click="handleBackClick" :color="backButtonColor">
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<!-- 메뉴 버튼 (모바일용) -->
<v-app-bar-nav-icon
v-if="showMenuButton"
@click="$emit('toggle-drawer')"
:color="menuButtonColor"
/>
<!-- 타이틀 -->
<v-app-bar-title :class="titleClass" @click="handleTitleClick">
{{ title }}
</v-app-bar-title>
<v-spacer />
<!-- 액션 버튼들 -->
<template v-for="action in actions" :key="action.id">
<v-btn
v-if="action.type === 'icon'"
:icon="action.icon"
@click="handleActionClick(action)"
:color="action.color || 'white'"
:loading="action.loading"
:disabled="action.disabled"
>
<v-icon>{{ action.icon }}</v-icon>
<v-badge
v-if="action.badge && action.badgeCount > 0"
:content="action.badgeCount"
:color="action.badgeColor || 'error'"
offset-x="8"
offset-y="8"
/>
</v-btn>
<v-btn
v-else
:color="action.color || 'primary'"
:variant="action.variant || 'text'"
@click="handleActionClick(action)"
:loading="action.loading"
:disabled="action.disabled"
>
{{ action.text }}
</v-btn>
</template>
<!-- 사용자 아바타 -->
<v-menu v-if="showUserMenu" offset-y :close-on-content-click="false">
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props" class="ml-2">
<v-avatar size="32" :color="avatarColor">
<v-img v-if="userAvatar" :src="userAvatar" :alt="userName" />
<span v-else class="text-white font-weight-bold">
{{ userInitial }}
</span>
</v-avatar>
</v-btn>
</template>
<v-card min-width="200">
<v-card-title class="text-center">
{{ userName }}
</v-card-title>
<v-card-subtitle class="text-center">
{{ userEmail }}
</v-card-subtitle>
<v-divider />
<v-list density="compact">
<v-list-item
v-for="menuItem in userMenuItems"
:key="menuItem.id"
@click="handleUserMenuClick(menuItem)"
:prepend-icon="menuItem.icon"
>
<v-list-item-title>{{ menuItem.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</v-app-bar>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
/**
* AI 마케팅 서비스 - 공통 헤더 컴포넌트
* 모바일 최적화된 앱바 컴포넌트
*/
// Props
const props = defineProps({
//
title: {
type: String,
default: 'AI 마케팅',
},
color: {
type: String,
default: 'primary',
},
elevation: {
type: [Number, String],
default: 2,
},
height: {
type: [Number, String],
default: 56,
},
//
showBackButton: {
type: Boolean,
default: false,
},
showMenuButton: {
type: Boolean,
default: true,
},
backButtonColor: {
type: String,
default: 'white',
},
menuButtonColor: {
type: String,
default: 'white',
},
//
titleClickable: {
type: Boolean,
default: false,
},
//
actions: {
type: Array,
default: () => [],
},
//
showUserMenu: {
type: Boolean,
default: true,
},
userName: {
type: String,
default: '사용자',
},
userEmail: {
type: String,
default: '',
},
userAvatar: {
type: String,
default: '',
},
avatarColor: {
type: String,
default: 'secondary',
},
userMenuItems: {
type: Array,
default: () => [
{ id: 'profile', title: '프로필', icon: 'mdi-account' },
{ id: 'settings', title: '설정', icon: 'mdi-cog' },
{ id: 'logout', title: '로그아웃', icon: 'mdi-logout' },
],
},
})
// Emits
const emit = defineEmits([
'toggle-drawer',
'back-click',
'title-click',
'action-click',
'user-menu-click',
])
//
const router = useRouter()
// Computed
const titleClass = computed(() => ({
'cursor-pointer': props.titleClickable,
'text-white': props.color === 'primary',
}))
const userInitial = computed(() => {
return props.userName.charAt(0).toUpperCase()
})
// Methods
const handleBackClick = () => {
emit('back-click')
router.go(-1)
}
const handleTitleClick = () => {
if (props.titleClickable) {
emit('title-click')
}
}
const handleActionClick = (action) => {
emit('action-click', action)
}
const handleUserMenuClick = (menuItem) => {
emit('user-menu-click', menuItem)
}
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,246 @@
//* src/components/common/ConfirmDialog.vue
<template>
<v-dialog
v-model="isVisible"
:max-width="maxWidth"
:persistent="persistent"
:scrollable="scrollable"
>
<v-card :color="cardColor" :elevation="elevation">
<!-- 헤더 -->
<v-card-title :class="titleClass" class="d-flex align-center">
<v-icon v-if="icon" :color="iconColor" :size="iconSize" class="mr-3">
{{ icon }}
</v-icon>
{{ title }}
</v-card-title>
<!-- 내용 -->
<v-card-text :class="textClass">
<div v-if="message" class="text-body-1 mb-4">
{{ message }}
</div>
<!-- 커스텀 슬롯 -->
<slot />
<!-- 상세 정보 -->
<div v-if="details" class="text-caption text-grey-600 mt-4">
{{ details }}
</div>
</v-card-text>
<!-- 액션 버튼 -->
<v-card-actions class="px-6 pb-6">
<v-spacer v-if="!fullWidthButtons" />
<div :class="buttonContainerClass">
<!-- 취소 버튼 -->
<v-btn
v-if="showCancelButton"
:color="cancelButtonColor"
:variant="cancelButtonVariant"
:size="buttonSize"
:loading="cancelLoading"
:disabled="disabled"
@click="handleCancel"
:class="{ 'flex-1': fullWidthButtons }"
>
{{ cancelText }}
</v-btn>
<!-- 확인 버튼 -->
<v-btn
:color="confirmButtonColor"
:variant="confirmButtonVariant"
:size="buttonSize"
:loading="confirmLoading"
:disabled="disabled"
@click="handleConfirm"
:class="{ 'flex-1': fullWidthButtons }"
>
{{ confirmText }}
</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, watch } from 'vue'
/**
* AI 마케팅 서비스 - 확인 다이얼로그 컴포넌트
* 사용자 확인이 필요한 액션에 사용되는 재사용 가능한 컴포넌트
*/
// Props
const props = defineProps({
//
modelValue: {
type: Boolean,
default: false,
},
//
maxWidth: {
type: [String, Number],
default: 400,
},
persistent: {
type: Boolean,
default: false,
},
scrollable: {
type: Boolean,
default: false,
},
//
cardColor: {
type: String,
default: 'white',
},
elevation: {
type: [Number, String],
default: 8,
},
//
title: {
type: String,
default: '확인',
},
icon: {
type: String,
default: '',
},
iconColor: {
type: String,
default: 'warning',
},
iconSize: {
type: [String, Number],
default: 'large',
},
//
message: {
type: String,
default: '',
},
details: {
type: String,
default: '',
},
//
confirmText: {
type: String,
default: '확인',
},
cancelText: {
type: String,
default: '취소',
},
confirmButtonColor: {
type: String,
default: 'primary',
},
cancelButtonColor: {
type: String,
default: 'grey',
},
confirmButtonVariant: {
type: String,
default: 'flat',
},
cancelButtonVariant: {
type: String,
default: 'outlined',
},
buttonSize: {
type: String,
default: 'default',
},
showCancelButton: {
type: Boolean,
default: true,
},
fullWidthButtons: {
type: Boolean,
default: false,
},
//
confirmLoading: {
type: Boolean,
default: false,
},
cancelLoading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
// Computed
const isVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const titleClass = computed(() => ({
'text-warning': props.icon && props.iconColor === 'warning',
'text-error': props.icon && props.iconColor === 'error',
'text-success': props.icon && props.iconColor === 'success',
'text-info': props.icon && props.iconColor === 'info',
}))
const textClass = computed(() => ({
'pt-4': true,
}))
const buttonContainerClass = computed(() => ({
'd-flex': true,
'gap-3': true,
'w-100': props.fullWidthButtons,
}))
// Methods
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
emit('cancel')
isVisible.value = false
}
// ESC (persistent true )
watch(
() => props.persistent,
(newVal) => {
if (!newVal && !isVisible.value) {
emit('cancel')
}
},
)
</script>
<style scoped>
.flex-1 {
flex: 1;
}
.gap-3 {
gap: 12px;
}
</style>

View File

@ -0,0 +1,435 @@
//* src/components/common/ContentCard.vue
<template>
<v-card :class="cardClass" :elevation="elevation" :color="cardColor" @click="handleCardClick">
<!-- 카드 헤더 -->
<div v-if="showHeader" class="card-header">
<div class="header-content">
<!-- 타입 아이콘 -->
<v-icon v-if="typeIcon" :color="typeIconColor" size="small" class="mr-2">
{{ typeIcon }}
</v-icon>
<!-- 제목 -->
<div class="header-title">
<div class="text-body-2 font-weight-medium">
{{ title }}
</div>
<div v-if="subtitle" class="text-caption text-grey-600">
{{ subtitle }}
</div>
</div>
</div>
<!-- 헤더 액션 -->
<div v-if="headerActions.length > 0" class="header-actions">
<v-btn
v-for="action in headerActions"
:key="action.id"
:icon="action.icon"
:color="action.color || 'grey'"
:size="action.size || 'small'"
variant="text"
@click.stop="handleActionClick(action)"
>
<v-icon>{{ action.icon }}</v-icon>
</v-btn>
</div>
</div>
<!-- 이미지 섹션 -->
<div v-if="image" class="image-section">
<v-img
:src="image"
:alt="imageAlt"
:aspect-ratio="imageAspectRatio"
cover
class="content-image"
>
<!-- 이미지 오버레이 -->
<div v-if="imageOverlay" class="image-overlay">
<slot name="image-overlay">
{{ imageOverlay }}
</slot>
</div>
</v-img>
</div>
<!-- 컨텐츠 영역 -->
<v-card-text :class="contentClass">
<!-- 상태 -->
<div v-if="status" class="status-section mb-3">
<v-chip :color="statusColor" :variant="statusVariant" size="small">
{{ status }}
</v-chip>
</div>
<!-- 메인 컨텐츠 -->
<div class="main-content">
<slot>
<div v-if="content" class="content-text">
{{ content }}
</div>
</slot>
</div>
<!-- 메타 정보 -->
<div v-if="showMeta" class="meta-section mt-3">
<div class="meta-row">
<div v-if="platform" class="meta-item">
<v-icon size="x-small" class="mr-1">mdi-web</v-icon>
<span class="text-caption">{{ platform }}</span>
</div>
<div v-if="createdAt" class="meta-item">
<v-icon size="x-small" class="mr-1">mdi-clock-outline</v-icon>
<span class="text-caption">{{ formatDate(createdAt) }}</span>
</div>
<div v-if="viewCount" class="meta-item">
<v-icon size="x-small" class="mr-1">mdi-eye-outline</v-icon>
<span class="text-caption">{{ viewCount }}</span>
</div>
</div>
</div>
<!-- 해시태그 -->
<div v-if="hashtags && hashtags.length > 0" class="hashtags-section mt-3">
<v-chip
v-for="(tag, index) in displayHashtags"
:key="index"
size="x-small"
variant="outlined"
color="primary"
class="mr-1 mb-1"
>
#{{ tag }}
</v-chip>
<v-btn
v-if="hashtags.length > maxHashtags"
variant="text"
size="x-small"
@click.stop="showAllHashtags = !showAllHashtags"
>
{{ showAllHashtags ? '간략히' : `+${hashtags.length - maxHashtags}개 더` }}
</v-btn>
</div>
</v-card-text>
<!-- 카드 액션 -->
<v-card-actions v-if="actions.length > 0" class="px-4 pb-4">
<v-spacer v-if="actionsAlign === 'right'" />
<v-btn
v-for="action in actions"
:key="action.id"
:color="action.color || 'primary'"
:variant="action.variant || 'text'"
:size="action.size || 'small'"
:loading="action.loading"
:disabled="action.disabled"
@click.stop="handleActionClick(action)"
>
<v-icon v-if="action.icon" :start="action.text" size="small">
{{ action.icon }}
</v-icon>
{{ action.text }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref, computed } from 'vue'
/**
* AI 마케팅 서비스 - 콘텐츠 카드 컴포넌트
* 마케팅 콘텐츠를 표시하는 재사용 가능한 카드 컴포넌트
*/
// Props
const props = defineProps({
//
elevation: {
type: [Number, String],
default: 2,
},
cardColor: {
type: String,
default: 'white',
},
clickable: {
type: Boolean,
default: false,
},
//
showHeader: {
type: Boolean,
default: true,
},
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
typeIcon: {
type: String,
default: '',
},
typeIconColor: {
type: String,
default: 'primary',
},
headerActions: {
type: Array,
default: () => [],
},
//
image: {
type: String,
default: '',
},
imageAlt: {
type: String,
default: '',
},
imageAspectRatio: {
type: [Number, String],
default: '16/9',
},
imageOverlay: {
type: String,
default: '',
},
//
content: {
type: String,
default: '',
},
status: {
type: String,
default: '',
},
statusColor: {
type: String,
default: 'primary',
},
statusVariant: {
type: String,
default: 'tonal',
},
//
showMeta: {
type: Boolean,
default: true,
},
platform: {
type: String,
default: '',
},
createdAt: {
type: [String, Date],
default: '',
},
viewCount: {
type: [Number, String],
default: '',
},
//
hashtags: {
type: Array,
default: () => [],
},
maxHashtags: {
type: Number,
default: 3,
},
//
actions: {
type: Array,
default: () => [],
},
actionsAlign: {
type: String,
default: 'left',
validator: (value) => ['left', 'center', 'right'].includes(value),
},
})
// Emits
const emit = defineEmits(['click', 'action-click'])
// Reactive data
const showAllHashtags = ref(false)
// Computed
const cardClass = computed(() => ({
'content-card': true,
'content-card--clickable': props.clickable,
'hover-elevation': props.clickable,
}))
const contentClass = computed(() => ({
'pt-4': !props.image,
'pt-3': props.image,
}))
const displayHashtags = computed(() => {
if (showAllHashtags.value || props.hashtags.length <= props.maxHashtags) {
return props.hashtags
}
return props.hashtags.slice(0, props.maxHashtags)
})
// Methods
const handleCardClick = () => {
if (props.clickable) {
emit('click')
}
}
const handleActionClick = (action) => {
emit('action-click', action)
}
const formatDate = (date) => {
if (!date) return ''
const dateObj = new Date(date)
const now = new Date()
const diffMs = now - dateObj
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) {
return '오늘'
} else if (diffDays === 1) {
return '어제'
} else if (diffDays < 7) {
return `${diffDays}일 전`
} else {
return dateObj.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric',
})
}
}
</script>
<style scoped>
.content-card {
transition: all 0.3s ease;
border-radius: 12px;
overflow: hidden;
}
.content-card--clickable {
cursor: pointer;
}
.content-card--clickable:hover {
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 16px 0 16px;
}
.header-content {
display: flex;
align-items: flex-start;
flex: 1;
}
.header-title {
flex: 1;
}
.header-actions {
display: flex;
gap: 4px;
}
.image-section {
position: relative;
}
.content-image {
border-radius: 0;
}
.image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 16px;
}
.status-section {
display: flex;
align-items: center;
gap: 8px;
}
.main-content {
margin-bottom: 8px;
}
.content-text {
line-height: 1.6;
color: rgba(0, 0, 0, 0.87);
}
.meta-section {
border-top: 1px solid #e0e0e0;
padding-top: 12px;
}
.meta-row {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
color: #757575;
}
.hashtags-section {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
@media (max-width: 600px) {
.card-header {
padding: 12px 12px 0 12px;
}
.meta-row {
gap: 12px;
}
.image-overlay {
padding: 12px;
}
}
</style>

View File

@ -0,0 +1,345 @@
//* src/components/common/ErrorAlert.vue
<template>
<v-alert
v-model="isVisible"
:type="alertType"
:variant="variant"
:density="density"
:elevation="elevation"
:color="alertColor"
:icon="alertIcon"
:closable="closable"
:class="alertClass"
@click:close="handleClose"
>
<!-- 제목 -->
<template v-if="title" v-slot:title>
<div class="d-flex align-center">
{{ title }}
<v-spacer />
<v-btn
v-if="showRetryButton"
variant="outlined"
size="small"
:color="retryButtonColor"
:loading="retryLoading"
@click="handleRetry"
class="ml-3"
>
{{ retryText }}
</v-btn>
</div>
</template>
<!-- 메시지 -->
<div class="alert-content">
<div v-if="message" class="alert-message">
{{ message }}
</div>
<!-- 커스텀 슬롯 -->
<slot />
<!-- 상세 정보 -->
<div v-if="details" class="alert-details mt-2">
<v-expansion-panels variant="accordion" flat>
<v-expansion-panel>
<v-expansion-panel-title class="text-caption"> 상세 정보 보기 </v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="text-caption">{{ details }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<!-- 추천 액션 -->
<div v-if="recommendations && recommendations.length > 0" class="alert-recommendations mt-3">
<div class="text-caption text-grey-700 mb-2">해결 방법:</div>
<ul class="text-caption">
<li v-for="(rec, index) in recommendations" :key="index" class="mb-1">
{{ rec }}
</li>
</ul>
</div>
</div>
<!-- 액션 버튼들 -->
<template v-if="actions.length > 0" v-slot:append>
<div class="alert-actions">
<v-btn
v-for="action in actions"
:key="action.id"
:variant="action.variant || 'text'"
:color="action.color || alertType"
:size="action.size || 'small'"
:loading="action.loading"
:disabled="action.disabled"
@click="handleActionClick(action)"
class="ml-2"
>
<v-icon v-if="action.icon" size="small" class="mr-1">
{{ action.icon }}
</v-icon>
{{ action.text }}
</v-btn>
</div>
</template>
</v-alert>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
/**
* AI 마케팅 서비스 - 에러 알림 컴포넌트
* 다양한 종류의 에러 알림 메시지를 표시하는 컴포넌트
*/
// Props
const props = defineProps({
//
modelValue: {
type: Boolean,
default: true,
},
//
type: {
type: String,
default: 'error',
validator: (value) => ['error', 'warning', 'info', 'success'].includes(value),
},
//
variant: {
type: String,
default: 'tonal',
validator: (value) => ['flat', 'tonal', 'outlined', 'text', 'elevated'].includes(value),
},
density: {
type: String,
default: 'default',
validator: (value) => ['default', 'comfortable', 'compact'].includes(value),
},
elevation: {
type: [Number, String],
default: 0,
},
color: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
//
closable: {
type: Boolean,
default: true,
},
autoHide: {
type: Boolean,
default: false,
},
autoHideDelay: {
type: Number,
default: 5000,
},
//
title: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
details: {
type: String,
default: '',
},
recommendations: {
type: Array,
default: () => [],
},
//
showRetryButton: {
type: Boolean,
default: false,
},
retryText: {
type: String,
default: '다시 시도',
},
retryButtonColor: {
type: String,
default: 'primary',
},
retryLoading: {
type: Boolean,
default: false,
},
//
actions: {
type: Array,
default: () => [],
},
})
// Emits
const emit = defineEmits(['update:modelValue', 'close', 'retry', 'action-click'])
// Reactive data
const autoHideTimer = ref(null)
// Computed
const isVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const alertType = computed(() => props.type)
const alertColor = computed(() => {
if (props.color) return props.color
return props.type
})
const alertIcon = computed(() => {
if (props.icon) return props.icon
const iconMap = {
error: 'mdi-alert-circle',
warning: 'mdi-alert',
info: 'mdi-information',
success: 'mdi-check-circle',
}
return iconMap[props.type] || 'mdi-information'
})
const alertClass = computed(() => ({
'error-alert': true,
[`error-alert--${props.type}`]: true,
}))
// Methods
const handleClose = () => {
isVisible.value = false
emit('close')
clearAutoHideTimer()
}
const handleRetry = () => {
emit('retry')
}
const handleActionClick = (action) => {
emit('action-click', action)
}
const startAutoHideTimer = () => {
if (props.autoHide && props.autoHideDelay > 0) {
autoHideTimer.value = setTimeout(() => {
handleClose()
}, props.autoHideDelay)
}
}
const clearAutoHideTimer = () => {
if (autoHideTimer.value) {
clearTimeout(autoHideTimer.value)
autoHideTimer.value = null
}
}
//
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
startAutoHideTimer()
} else {
clearAutoHideTimer()
}
},
{ immediate: true },
)
//
import { onUnmounted } from 'vue'
onUnmounted(() => {
clearAutoHideTimer()
})
</script>
<style scoped>
.error-alert {
margin: 16px 0;
}
.alert-content {
line-height: 1.6;
}
.alert-message {
font-weight: 500;
}
.alert-details pre {
background: rgba(0, 0, 0, 0.05);
padding: 8px;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.alert-recommendations ul {
margin: 0;
padding-left: 16px;
}
.alert-actions {
display: flex;
align-items: center;
}
/* 타입별 커스텀 스타일 */
.error-alert--error {
border-left: 4px solid rgb(var(--v-theme-error));
}
.error-alert--warning {
border-left: 4px solid rgb(var(--v-theme-warning));
}
.error-alert--info {
border-left: 4px solid rgb(var(--v-theme-info));
}
.error-alert--success {
border-left: 4px solid rgb(var(--v-theme-success));
}
@media (max-width: 600px) {
.error-alert {
margin: 12px 0;
}
.alert-actions {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.alert-actions .v-btn {
margin: 0 !important;
}
}
</style>

View File

@ -0,0 +1,454 @@
//* src/components/common/ImageUpload.vue
<template>
<div class="image-upload-container">
<!-- 업로드 영역 -->
<div
ref="dropZone"
:class="dropZoneClass"
@click="openFileDialog"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<!-- 파일 선택 input -->
<input
ref="fileInput"
type="file"
:accept="acceptTypes"
:multiple="multiple"
@change="handleFileSelect"
style="display: none"
/>
<!-- 업로드 아이콘 텍스트 -->
<div v-if="!hasImages" class="upload-prompt">
<v-icon :size="iconSize" :color="iconColor" class="mb-4">
{{ uploadIcon }}
</v-icon>
<div class="text-h6 font-weight-medium mb-2">
{{ uploadText }}
</div>
<div class="text-body-2 text-grey-600 mb-4">
{{ uploadSubText }}
</div>
<v-chip :color="chipColor" variant="outlined" size="small">
{{ acceptTypesText }} | 최대 {{ maxSizeText }}
</v-chip>
</div>
<!-- 이미지 미리보기 그리드 -->
<div v-else class="image-preview-grid">
<div v-for="(image, index) in previewImages" :key="index" class="image-preview-item">
<!-- 이미지 -->
<div class="image-wrapper">
<v-img :src="image.url" :alt="image.name" aspect-ratio="1" cover class="rounded" />
<!-- 삭제 버튼 -->
<v-btn
icon
size="small"
color="error"
class="delete-btn"
@click.stop="removeImage(index)"
>
<v-icon size="small">mdi-close</v-icon>
</v-btn>
<!-- 메인 이미지 표시 -->
<v-chip
v-if="index === 0 && multiple"
color="primary"
size="x-small"
class="main-badge"
>
메인
</v-chip>
</div>
<!-- 이미지 정보 -->
<div class="image-info">
<div class="text-caption font-weight-medium">
{{ image.name }}
</div>
<div class="text-caption text-grey-600">
{{ formatFileSize(image.size) }}
</div>
</div>
</div>
<!-- 추가 업로드 버튼 (다중 업로드시) -->
<div
v-if="multiple && previewImages.length < maxFiles"
class="add-more-btn"
@click.stop="openFileDialog"
>
<v-icon size="large" color="grey-400">mdi-plus</v-icon>
<div class="text-caption text-grey-600 mt-2">추가 업로드</div>
</div>
</div>
</div>
<!-- 에러 메시지 -->
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
class="mt-4"
@click:close="clearError"
closable
>
{{ errorMessage }}
</v-alert>
<!-- 업로드 진행률 -->
<v-progress-linear
v-if="uploading"
:model-value="uploadProgress"
color="primary"
height="4"
class="mt-4"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
/**
* AI 마케팅 서비스 - 이미지 업로드 컴포넌트
* 드래그&드롭 클릭 업로드를 지원하는 이미지 업로드 컴포넌트
*/
// Props
const props = defineProps({
// ( )
modelValue: {
type: Array,
default: () => [],
},
//
multiple: {
type: Boolean,
default: false,
},
maxFiles: {
type: Number,
default: 5,
},
maxSize: {
type: Number,
default: 10 * 1024 * 1024, // 10MB
},
acceptTypes: {
type: String,
default: 'image/jpeg,image/png,image/webp',
},
// UI
uploadText: {
type: String,
default: '이미지를 업로드하세요',
},
uploadSubText: {
type: String,
default: '클릭하거나 파일을 드래그하세요',
},
uploadIcon: {
type: String,
default: 'mdi-cloud-upload',
},
iconSize: {
type: [String, Number],
default: 64,
},
iconColor: {
type: String,
default: 'grey-400',
},
chipColor: {
type: String,
default: 'primary',
},
//
enableDragDrop: {
type: Boolean,
default: true,
},
})
// Emits
const emit = defineEmits(['update:modelValue', 'upload', 'error'])
// Reactive data
const fileInput = ref(null)
const dropZone = ref(null)
const isDragging = ref(false)
const errorMessage = ref('')
const uploading = ref(false)
const uploadProgress = ref(0)
const previewImages = ref([])
// Computed
const hasImages = computed(() => previewImages.value.length > 0)
const dropZoneClass = computed(() => ({
'drop-zone': true,
'drop-zone--dragging': isDragging.value,
'drop-zone--has-images': hasImages.value,
clickable: true,
}))
const acceptTypesText = computed(() => {
return props.acceptTypes
.split(',')
.map((type) => type.split('/')[1].toUpperCase())
.join(', ')
})
const maxSizeText = computed(() => {
const size = props.maxSize
if (size >= 1024 * 1024) {
return `${Math.round(size / (1024 * 1024))}MB`
}
return `${Math.round(size / 1024)}KB`
})
// Methods
const openFileDialog = () => {
fileInput.value?.click()
}
const handleFileSelect = (event) => {
const files = Array.from(event.target.files)
processFiles(files)
// input
event.target.value = ''
}
const handleDragOver = (event) => {
if (!props.enableDragDrop) return
event.dataTransfer.dropEffect = 'copy'
isDragging.value = true
}
const handleDragLeave = () => {
if (!props.enableDragDrop) return
isDragging.value = false
}
const handleDrop = (event) => {
if (!props.enableDragDrop) return
isDragging.value = false
const files = Array.from(event.dataTransfer.files)
processFiles(files)
}
const processFiles = (files) => {
clearError()
//
const validFiles = []
const acceptedTypes = props.acceptTypes.split(',').map((type) => type.trim())
for (const file of files) {
//
if (!acceptedTypes.includes(file.type)) {
setError(`${file.name}: 지원하지 않는 파일 형식입니다.`)
continue
}
//
if (file.size > props.maxSize) {
setError(`${file.name}: 파일 크기가 너무 큽니다. (최대: ${maxSizeText.value})`)
continue
}
//
if (previewImages.value.length + validFiles.length >= props.maxFiles) {
setError(`최대 ${props.maxFiles}개의 파일만 업로드할 수 있습니다.`)
break
}
validFiles.push(file)
}
//
validFiles.forEach((file) => {
const reader = new FileReader()
reader.onload = (e) => {
const imageData = {
file,
url: e.target.result,
name: file.name,
size: file.size,
}
if (props.multiple) {
previewImages.value.push(imageData)
} else {
previewImages.value = [imageData]
}
updateModelValue()
}
reader.readAsDataURL(file)
})
}
const removeImage = (index) => {
previewImages.value.splice(index, 1)
updateModelValue()
}
const updateModelValue = () => {
const files = previewImages.value.map((img) => img.file)
emit('update:modelValue', files)
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const setError = (message) => {
errorMessage.value = message
emit('error', message)
}
const clearError = () => {
errorMessage.value = ''
}
// Watch for external model value changes
watch(
() => props.modelValue,
(newFiles) => {
if (newFiles.length === 0) {
previewImages.value = []
}
},
{ deep: true },
)
</script>
<style scoped>
.image-upload-container {
width: 100%;
}
.drop-zone {
border: 2px dashed #e0e0e0;
border-radius: 12px;
padding: 24px;
text-align: center;
transition: all 0.3s ease;
background-color: #fafafa;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.drop-zone.clickable {
cursor: pointer;
}
.drop-zone:hover {
border-color: #1976d2;
background-color: #f3f8ff;
}
.drop-zone--dragging {
border-color: #1976d2;
background-color: #e3f2fd;
transform: scale(1.02);
}
.drop-zone--has-images {
padding: 16px;
min-height: auto;
}
.upload-prompt {
display: flex;
flex-direction: column;
align-items: center;
}
.image-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 16px;
width: 100%;
}
.image-preview-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.image-wrapper {
position: relative;
}
.delete-btn {
position: absolute;
top: 4px;
right: 4px;
background-color: rgba(255, 255, 255, 0.9) !important;
}
.main-badge {
position: absolute;
bottom: 4px;
left: 4px;
}
.add-more-btn {
border: 2px dashed #e0e0e0;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
cursor: pointer;
transition: all 0.3s ease;
}
.add-more-btn:hover {
border-color: #1976d2;
background-color: #f3f8ff;
}
.image-info {
text-align: center;
}
@media (max-width: 600px) {
.image-preview-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.drop-zone {
padding: 16px;
min-height: 160px;
}
}
</style>

View File

@ -0,0 +1,136 @@
//* src/components/common/LoadingSpinner.vue
<template>
<div :class="wrapperClass" v-if="show">
<!-- 오버레이 배경 -->
<v-overlay
v-if="overlay"
:model-value="show"
:persistent="persistent"
:opacity="overlayOpacity"
class="d-flex align-center justify-center"
>
<v-card :elevation="cardElevation" class="pa-8 text-center" :color="cardColor" rounded="lg">
<v-progress-circular
:size="size"
:width="width"
:color="color"
indeterminate
class="mb-4"
/>
<div v-if="message" :class="messageClass">
{{ message }}
</div>
<div v-if="subMessage" class="text-caption text-grey-600 mt-2">
{{ subMessage }}
</div>
</v-card>
</v-overlay>
<!-- 인라인 스피너 -->
<div v-else class="d-flex align-center justify-center flex-column">
<v-progress-circular
:size="size"
:width="width"
:color="color"
indeterminate
:class="spinnerClass"
/>
<div v-if="message" :class="messageClass">
{{ message }}
</div>
<div v-if="subMessage" class="text-caption text-grey-600 mt-2">
{{ subMessage }}
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
/**
* AI 마케팅 서비스 - 로딩 스피너 컴포넌트
* 다양한 로딩 상태를 표시하는 재사용 가능한 컴포넌트
*/
// Props
const props = defineProps({
//
show: {
type: Boolean,
default: true,
},
//
size: {
type: [Number, String],
default: 64,
},
width: {
type: [Number, String],
default: 4,
},
color: {
type: String,
default: 'primary',
},
//
overlay: {
type: Boolean,
default: false,
},
persistent: {
type: Boolean,
default: true,
},
overlayOpacity: {
type: Number,
default: 0.7,
},
// ( )
cardColor: {
type: String,
default: 'white',
},
cardElevation: {
type: [Number, String],
default: 8,
},
//
message: {
type: String,
default: '',
},
subMessage: {
type: String,
default: '',
},
//
wrapperClass: {
type: [String, Array, Object],
default: '',
},
spinnerClass: {
type: [String, Array, Object],
default: '',
},
})
// Computed
const messageClass = computed(() => ({
'text-h6': props.overlay,
'text-body-1': !props.overlay,
'font-weight-medium': true,
'text-grey-700': true,
'mt-4': props.overlay,
'mt-2': !props.overlay,
}))
</script>

View File

@ -0,0 +1,288 @@
//* src/components/layout/BottomNavigation.vue
<template>
<v-bottom-navigation
v-model="activeTab"
:color="color"
:bg-color="bgColor"
:elevation="elevation"
:height="height"
:grow="grow"
:shift="shift"
:selected-class="selectedClass"
class="bottom-nav"
>
<v-btn
v-for="item in navigationItems"
:key="item.id"
:value="item.id"
:to="item.route"
:color="getItemColor(item)"
:stacked="stacked"
@click="handleNavClick(item)"
>
<!-- 배지가 있는 아이콘 -->
<v-badge
v-if="item.badge && item.badgeCount > 0"
:content="item.badgeCount"
:color="item.badgeColor || 'error'"
offset-x="8"
offset-y="8"
>
<v-icon :size="iconSize">{{ item.icon }}</v-icon>
</v-badge>
<!-- 일반 아이콘 -->
<v-icon v-else :size="iconSize">{{ item.icon }}</v-icon>
<!-- 라벨 -->
<span class="nav-label">{{ item.label }}</span>
<!-- 새로운 기능 표시 -->
<v-chip v-if="item.isNew" color="error" size="x-small" class="new-badge"> NEW </v-chip>
</v-btn>
</v-bottom-navigation>
</template>
<script setup>
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/**
* AI 마케팅 서비스 - 하단 네비게이션 컴포넌트
* 모바일 최적화된 하단 네비게이션
*/
// Props
const props = defineProps({
//
modelValue: {
type: String,
default: '',
},
//
color: {
type: String,
default: 'primary',
},
bgColor: {
type: String,
default: 'white',
},
elevation: {
type: [Number, String],
default: 8,
},
height: {
type: [Number, String],
default: 64,
},
grow: {
type: Boolean,
default: false,
},
shift: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: true,
},
selectedClass: {
type: String,
default: 'v-btn--active',
},
iconSize: {
type: [String, Number],
default: 24,
},
//
navigationItems: {
type: Array,
default: () => [
{
id: 'dashboard',
label: '홈',
icon: 'mdi-home',
route: '/dashboard',
activeIcon: 'mdi-home',
},
{
id: 'store',
label: '매장',
icon: 'mdi-store-outline',
activeIcon: 'mdi-store',
route: '/store',
},
{
id: 'content',
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: 'menu',
label: '더보기',
icon: 'mdi-menu',
activeIcon: 'mdi-menu',
route: '/menu',
},
],
},
//
autoRoute: {
type: Boolean,
default: true,
},
//
hideOnRoutes: {
type: Array,
default: () => ['/login', '/register'],
},
})
// Emits
const emit = defineEmits(['update:modelValue', 'nav-click'])
//
const route = useRoute()
const router = useRouter()
// Computed
const activeTab = computed({
get: () => {
if (props.modelValue) return props.modelValue
//
const currentPath = route.path
const matchedItem = props.navigationItems.find((item) => {
if (!item.route) return false
return currentPath === item.route || currentPath.startsWith(item.route + '/')
})
return matchedItem?.id || props.navigationItems[0]?.id || ''
},
set: (value) => emit('update:modelValue', value),
})
const shouldHide = computed(() => {
return props.hideOnRoutes.includes(route.path)
})
// Methods
const getItemColor = (item) => {
// primary ,
return activeTab.value === item.id ? props.color : 'grey'
}
const getItemIcon = (item) => {
// activeIcon , icon
return activeTab.value === item.id && item.activeIcon ? item.activeIcon : item.icon
}
const handleNavClick = (item) => {
emit('nav-click', item)
//
if (props.autoRoute && item.route) {
router.push(item.route)
}
}
//
watch(
() => route.path,
() => {
const matchedItem = props.navigationItems.find((item) => {
if (!item.route) return false
return route.path === item.route || route.path.startsWith(item.route + '/')
})
if (matchedItem && activeTab.value !== matchedItem.id) {
activeTab.value = matchedItem.id
}
},
)
</script>
<style scoped>
.bottom-nav {
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
.nav-label {
font-size: 12px;
font-weight: 500;
line-height: 1;
margin-top: 4px;
}
.new-badge {
position: absolute;
top: 4px;
right: 4px;
font-size: 8px;
min-width: 20px;
height: 16px;
}
/* 활성 상태 스타일 */
.v-btn--active .nav-label {
font-weight: 700;
}
.v-btn--active .v-icon {
transform: scale(1.1);
transition: transform 0.2s ease;
}
/* 배지 스타일 */
.v-badge :deep(.v-badge__badge) {
font-size: 10px;
min-width: 16px;
height: 16px;
border-radius: 8px;
}
/* 터치 영역 확장 */
.v-btn {
min-width: 64px;
padding: 8px 12px;
}
/* 아이콘 전환 애니메이션 */
.v-icon {
transition: all 0.2s ease;
}
/* 라벨 전환 애니메이션 */
.nav-label {
transition: all 0.2s ease;
}
/* 다크 테마 지원 */
@media (prefers-color-scheme: dark) {
.bottom-nav {
border-top-color: rgba(255, 255, 255, 0.12);
}
}
/* 안전 영역 지원 (노치가 있는 기기) */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom);
}
}
</style>

View File

@ -0,0 +1,658 @@
//* 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>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDisplay } from 'vuetify'
//
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()
// 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'
}
]
},
{
title: '매장 관리',
items: [
{
id: 'store',
title: '매장 정보',
icon: 'mdi-store',
route: '/store'
},
{
id: 'menu',
title: '메뉴 관리',
icon: 'mdi-food',
route: '/menu'
}
]
},
{
title: '마케팅',
items: [
{
id: 'content-create',
title: '콘텐츠 생성',
icon: 'mdi-plus-circle',
route: '/content/create'
},
{
id: 'content-list',
title: '콘텐츠 목록',
icon: 'mdi-file-document-multiple',
route: '/content'
},
{
id: 'ai-recommend',
title: 'AI 추천',
icon: 'mdi-robot',
route: '/recommend'
}
]
},
{
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
})
//
onMounted(() => {
//
window.addEventListener('orientationchange', handleOrientationChange)
//
if (!userStore.user) {
userStore.fetchUser().catch(error => {
setError({
title: '사용자 정보 로드 실패',
message: '사용자 정보를 불러올 수 없습니다.',
details: error.message,
retryable: true,
retryCallback: () => userStore.fetchUser()
})
})
}
})
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')
}
})
</script>
<style scoped>
.main-content {
transition: all 0.3s ease;
}
.main-content--rail {
/* Rail 모드일 때 왼쪽 여백 조정 */
margin-left: 72px;
}
.main-content--mobile {
/* 모바일에서는 하단 네비게이션 공간 확보 */
padding-bottom: 80px;
}
/* 페이지 전환 애니메이션 */
.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));
}
}

View File

@ -0,0 +1,475 @@
//* src/components/layout/Sidebar.vue
<template>
<v-navigation-drawer
v-model="isOpen"
:location="location"
:width="width"
:rail="rail"
:permanent="permanent"
:temporary="temporary"
:floating="floating"
:color="drawerColor"
:elevation="elevation"
:scrim="scrimColor"
>
<!-- 헤더 -->
<div class="sidebar-header">
<div class="header-content">
<!-- 로고 -->
<div class="sidebar-logo">
<v-img :src="logoSrc" :alt="logoAlt" :width="logoWidth" :height="logoHeight" contain />
</div>
<!-- 사용자 정보 -->
<div v-if="showUserInfo && !rail" class="user-info">
<v-avatar :size="avatarSize" :color="avatarColor" class="mb-2">
<v-img v-if="userAvatar" :src="userAvatar" :alt="userName" />
<span v-else class="text-white font-weight-bold">
{{ userInitial }}
</span>
</v-avatar>
<div class="user-details">
<div class="text-body-2 font-weight-medium">
{{ userName }}
</div>
<div class="text-caption text-grey-600">
{{ userRole }}
</div>
<div class="text-caption text-grey-600">
{{ businessName }}
</div>
</div>
</div>
</div>
<!-- 축소/확장 버튼 -->
<v-btn v-if="showToggleButton" icon size="small" @click="toggleRail" class="toggle-btn">
<v-icon>{{ rail ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<v-divider />
<!-- 네비게이션 메뉴 -->
<v-list :density="listDensity" nav class="sidebar-menu">
<!-- 메뉴 그룹 -->
<template v-for="(group, groupIndex) in menuGroups" :key="groupIndex">
<!-- 그룹 헤더 -->
<v-list-subheader v-if="group.title && !rail" class="text-grey-600 font-weight-medium">
{{ group.title }}
</v-list-subheader>
<!-- 메뉴 아이템들 -->
<template v-for="item in group.items" :key="item.id">
<!-- 단일 메뉴 아이템 -->
<v-list-item
v-if="!item.children"
:to="item.route"
:value="item.id"
:prepend-icon="item.icon"
:title="item.title"
:subtitle="item.subtitle"
:active="isActiveRoute(item.route)"
:class="getMenuItemClass(item)"
@click="handleMenuClick(item)"
>
<!-- 배지 -->
<template v-if="item.badge" v-slot:append>
<v-chip
:color="item.badgeColor || 'primary'"
size="x-small"
:variant="item.badgeVariant || 'flat'"
>
{{ item.badge }}
</v-chip>
</template>
</v-list-item>
<!-- 하위 메뉴가 있는 아이템 -->
<v-list-group v-else :value="item.id" :prepend-icon="item.icon">
<template v-slot:activator="{ props }">
<v-list-item
v-bind="props"
:title="item.title"
:subtitle="item.subtitle"
:class="getMenuItemClass(item)"
/>
</template>
<v-list-item
v-for="child in item.children"
:key="child.id"
:to="child.route"
:value="child.id"
:title="child.title"
:prepend-icon="child.icon"
:active="isActiveRoute(child.route)"
class="ml-4"
@click="handleMenuClick(child)"
>
<template v-if="child.badge" v-slot:append>
<v-chip
:color="child.badgeColor || 'primary'"
size="x-small"
:variant="child.badgeVariant || 'flat'"
>
{{ child.badge }}
</v-chip>
</template>
</v-list-item>
</v-list-group>
</template>
<!-- 그룹 구분선 -->
<v-divider v-if="groupIndex < menuGroups.length - 1" class="my-2" />
</template>
</v-list>
<!-- 하단 액션 -->
<template v-slot:append>
<div class="sidebar-footer">
<v-divider />
<v-list density="compact" nav>
<v-list-item
v-for="action in footerActions"
:key="action.id"
:prepend-icon="action.icon"
:title="action.title"
:color="action.color"
@click="handleActionClick(action)"
/>
</v-list>
</div>
</template>
</v-navigation-drawer>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
/**
* AI 마케팅 서비스 - 사이드바 네비게이션 컴포넌트
* 메인 네비게이션을 제공하는 사이드바
*/
// Props
const props = defineProps({
//
modelValue: {
type: Boolean,
default: true,
},
location: {
type: String,
default: 'start',
},
width: {
type: [Number, String],
default: 280,
},
rail: {
type: Boolean,
default: false,
},
permanent: {
type: Boolean,
default: false,
},
temporary: {
type: Boolean,
default: true,
},
floating: {
type: Boolean,
default: false,
},
//
drawerColor: {
type: String,
default: 'white',
},
elevation: {
type: [Number, String],
default: 1,
},
scrimColor: {
type: String,
default: 'rgba(0, 0, 0, 0.5)',
},
listDensity: {
type: String,
default: 'default',
},
//
logoSrc: {
type: String,
default: '/images/logo.png',
},
logoAlt: {
type: String,
default: 'AI 마케팅 로고',
},
logoWidth: {
type: [Number, String],
default: 40,
},
logoHeight: {
type: [Number, String],
default: 40,
},
//
showUserInfo: {
type: Boolean,
default: true,
},
userName: {
type: String,
default: '사용자',
},
userRole: {
type: String,
default: '점주',
},
businessName: {
type: String,
default: '',
},
userAvatar: {
type: String,
default: '',
},
avatarSize: {
type: [Number, String],
default: 48,
},
avatarColor: {
type: String,
default: 'primary',
},
//
showToggleButton: {
type: Boolean,
default: true,
},
//
menuGroups: {
type: Array,
default: () => [
{
title: '대시보드',
items: [
{
id: 'dashboard',
title: '홈',
icon: 'mdi-home',
route: '/dashboard',
},
],
},
{
title: '매장 관리',
items: [
{
id: 'store',
title: '매장 정보',
icon: 'mdi-store',
route: '/store',
},
{
id: 'menu',
title: '메뉴 관리',
icon: 'mdi-food',
route: '/menu',
},
],
},
{
title: '마케팅',
items: [
{
id: 'content-create',
title: '콘텐츠 생성',
icon: 'mdi-plus-circle',
route: '/content/create',
},
{
id: 'content-list',
title: '콘텐츠 목록',
icon: 'mdi-file-document-multiple',
route: '/content',
},
{
id: 'ai-recommend',
title: 'AI 추천',
icon: 'mdi-robot',
route: '/recommend',
},
],
},
{
title: '분석',
items: [
{
id: 'sales',
title: '매출 분석',
icon: 'mdi-chart-line',
route: '/sales',
},
],
},
],
},
//
footerActions: {
type: Array,
default: () => [
{
id: 'settings',
title: '설정',
icon: 'mdi-cog',
color: 'grey',
},
{
id: 'logout',
title: '로그아웃',
icon: 'mdi-logout',
color: 'error',
},
],
},
})
// Emits
const emit = defineEmits(['update:modelValue', 'update:rail', 'menu-click', 'action-click'])
//
const route = useRoute()
// Computed
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const userInitial = computed(() => {
return props.userName.charAt(0).toUpperCase()
})
// Methods
const toggleRail = () => {
emit('update:rail', !props.rail)
}
const isActiveRoute = (routePath) => {
if (!routePath) return false
return route.path === routePath || route.path.startsWith(routePath + '/')
}
const getMenuItemClass = (item) => {
return {
'menu-item': true,
'menu-item--important': item.important,
'menu-item--new': item.isNew,
}
}
const handleMenuClick = (item) => {
emit('menu-click', item)
//
if (props.temporary && window.innerWidth < 960) {
isOpen.value = false
}
}
const handleActionClick = (action) => {
emit('action-click', action)
}
</script>
<style scoped>
.sidebar-header {
position: relative;
padding: 20px 16px 16px;
background: linear-gradient(
135deg,
rgb(var(--v-theme-primary)),
rgb(var(--v-theme-primary-darken-1))
);
color: white;
}
.header-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.sidebar-logo {
margin-bottom: 16px;
}
.user-info {
width: 100%;
}
.user-details {
line-height: 1.4;
}
.toggle-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(255, 255, 255, 0.1) !important;
}
.sidebar-menu {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.menu-item--important {
background: rgba(var(--v-theme-primary), 0.1);
border-radius: 8px;
margin: 0 8px;
}
.menu-item--new::after {
content: 'NEW';
position: absolute;
top: 8px;
right: 8px;
background: rgb(var(--v-theme-error));
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: bold;
}
.sidebar-footer {
background: rgb(var(--v-theme-surface-variant));
}
@media (max-width: 960px) {
.sidebar-header {
padding: 16px 12px 12px;
}
.user-info {
margin-top: 12px;
}
}
</style>