release
This commit is contained in:
parent
43066ca623
commit
5f963a9cd9
228
src/components/common/AppHeader.vue
Normal file
228
src/components/common/AppHeader.vue
Normal 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>
|
||||||
246
src/components/common/ConfirmDialog.vue
Normal file
246
src/components/common/ConfirmDialog.vue
Normal 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>
|
||||||
435
src/components/common/ContentCard.vue
Normal file
435
src/components/common/ContentCard.vue
Normal 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>
|
||||||
345
src/components/common/ErrorAlert.vue
Normal file
345
src/components/common/ErrorAlert.vue
Normal 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>
|
||||||
454
src/components/common/ImageUpload.vue
Normal file
454
src/components/common/ImageUpload.vue
Normal 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>
|
||||||
136
src/components/common/LoadingSpinner.vue
Normal file
136
src/components/common/LoadingSpinner.vue
Normal 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>
|
||||||
288
src/components/layout/BottomNavigation.vue
Normal file
288
src/components/layout/BottomNavigation.vue
Normal 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>
|
||||||
658
src/components/layout/MainLayout.vue
Normal file
658
src/components/layout/MainLayout.vue
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
475
src/components/layout/Sidebar.vue
Normal file
475
src/components/layout/Sidebar.vue
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user