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