views files add
This commit is contained in:
parent
633ed906da
commit
93e27a239a
854
src/views/AIRecommendationView.vue
Normal file
854
src/views/AIRecommendationView.vue
Normal file
@ -0,0 +1,854 @@
|
||||
//* src/views/AIRecommendationView.vue
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- AI 추천 대시보드 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card
|
||||
class="pa-6"
|
||||
elevation="2"
|
||||
color="gradient-primary"
|
||||
>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="8">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-avatar
|
||||
size="64"
|
||||
color="white"
|
||||
class="mr-4"
|
||||
>
|
||||
<v-icon size="32" color="primary">mdi-robot</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<h2 class="text-h4 font-weight-bold white--text">
|
||||
AI 마케팅 어시스턴트
|
||||
</h2>
|
||||
<p class="text-h6 white--text opacity-90">
|
||||
데이터 기반 맞춤형 마케팅 전략을 제공합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="text-center">
|
||||
<v-btn
|
||||
size="large"
|
||||
color="white"
|
||||
class="text-primary"
|
||||
@click="generateAllRecommendations"
|
||||
:loading="generatingAll"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-refresh</v-icon>
|
||||
전체 추천 새로고침
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 오늘의 추천 카드 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="warning">mdi-star</v-icon>
|
||||
오늘의 특별 추천
|
||||
<v-spacer />
|
||||
<v-chip color="success" size="small">
|
||||
{{ getCurrentDate() }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-card
|
||||
v-if="todayRecommendation"
|
||||
class="pa-4 mb-4"
|
||||
color="amber-lighten-5"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="8">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon color="amber-darken-2" class="mr-2">mdi-lightbulb</v-icon>
|
||||
<h4 class="text-h6 font-weight-bold">
|
||||
{{ todayRecommendation.title }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-body-1 mb-3">
|
||||
{{ todayRecommendation.content }}
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="tag in todayRecommendation.tags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
color="amber"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="text-center">
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
@click="applyRecommendation(todayRecommendation)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-check-circle</v-icon>
|
||||
추천 적용하기
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<div class="text-right">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="generateTodayRecommendation"
|
||||
:loading="generatingToday"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-refresh</v-icon>
|
||||
새 추천 받기
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 추천 카테고리 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="info">mdi-view-grid</v-icon>
|
||||
AI 추천 카테고리
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="category in recommendationCategories"
|
||||
:key="category.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
>
|
||||
<v-card
|
||||
class="recommendation-category-card"
|
||||
:class="{ 'selected': selectedCategory === category.id }"
|
||||
@click="selectCategory(category.id)"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="text-center pa-6">
|
||||
<v-icon
|
||||
:color="category.color"
|
||||
size="48"
|
||||
class="mb-3"
|
||||
>
|
||||
{{ category.icon }}
|
||||
</v-icon>
|
||||
<h4 class="text-h6 font-weight-bold mb-2">
|
||||
{{ category.title }}
|
||||
</h4>
|
||||
<p class="text-body-2 grey--text">
|
||||
{{ category.description }}
|
||||
</p>
|
||||
<v-chip
|
||||
:color="category.color"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ category.count }}개 추천
|
||||
</v-chip>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 선택된 카테고리 추천 목록 -->
|
||||
<v-row v-if="selectedCategory">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" :color="getCategoryInfo(selectedCategory).color">
|
||||
{{ getCategoryInfo(selectedCategory).icon }}
|
||||
</v-icon>
|
||||
{{ getCategoryInfo(selectedCategory).title }} 추천
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="generateCategoryRecommendations(selectedCategory)"
|
||||
:loading="generatingCategory"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-refresh</v-icon>
|
||||
새로고침
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-row v-if="categoryRecommendations.length > 0">
|
||||
<v-col
|
||||
v-for="(recommendation, index) in categoryRecommendations"
|
||||
:key="index"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-card
|
||||
class="recommendation-item-card"
|
||||
elevation="1"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-start mb-3">
|
||||
<v-avatar
|
||||
size="40"
|
||||
:color="recommendation.priority === 'high' ? 'error' :
|
||||
recommendation.priority === 'medium' ? 'warning' : 'success'"
|
||||
class="mr-3"
|
||||
>
|
||||
<span class="white--text font-weight-bold">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
<div class="flex-grow-1">
|
||||
<h4 class="text-h6 font-weight-bold mb-1">
|
||||
{{ recommendation.title }}
|
||||
</h4>
|
||||
<p class="text-body-2 grey--text mb-2">
|
||||
{{ recommendation.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추천 이유 -->
|
||||
<v-card
|
||||
class="pa-3 mb-3"
|
||||
color="blue-grey-lighten-5"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="text-caption grey--text mb-1">추천 이유</div>
|
||||
<div class="text-body-2">{{ recommendation.reason }}</div>
|
||||
</v-card>
|
||||
|
||||
<!-- 예상 효과 -->
|
||||
<div class="mb-3">
|
||||
<div class="text-caption grey--text mb-1">예상 효과</div>
|
||||
<v-chip
|
||||
v-for="effect in recommendation.expectedEffects"
|
||||
:key="effect"
|
||||
size="small"
|
||||
color="green"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ effect }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 우선순위 -->
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-chip
|
||||
:color="recommendation.priority === 'high' ? 'error' :
|
||||
recommendation.priority === 'medium' ? 'warning' : 'success'"
|
||||
size="small"
|
||||
>
|
||||
{{ getPriorityText(recommendation.priority) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="info"
|
||||
size="small"
|
||||
>
|
||||
예상 소요시간: {{ recommendation.estimatedTime }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pt-0 px-4 pb-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="viewRecommendationDetail(recommendation)"
|
||||
>
|
||||
상세보기
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="success"
|
||||
size="small"
|
||||
@click="applyRecommendation(recommendation)"
|
||||
>
|
||||
적용하기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<v-card
|
||||
v-else-if="generatingCategory"
|
||||
class="pa-8 text-center"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<p class="text-body-1">AI가 추천을 분석하고 있습니다...</p>
|
||||
</v-card>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<v-card
|
||||
v-else
|
||||
class="pa-6 text-center"
|
||||
color="grey-lighten-4"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon size="48" color="grey" class="mb-2">mdi-robot-outline</v-icon>
|
||||
<p class="text-body-2">해당 카테고리의 추천을 생성해주세요</p>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 추천 상세 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDetailDialog"
|
||||
max-width="700"
|
||||
scrollable
|
||||
>
|
||||
<v-card v-if="selectedRecommendation">
|
||||
<v-card-title class="text-h6">
|
||||
{{ selectedRecommendation.title }}
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon
|
||||
@click="showDetailDialog = false"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-h6 mb-2">📋 추천 개요</h4>
|
||||
<p class="text-body-1">{{ selectedRecommendation.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 추천 이유 -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-h6 mb-2">🔍 추천 이유</h4>
|
||||
<v-card
|
||||
class="pa-3"
|
||||
color="blue-grey-lighten-5"
|
||||
variant="tonal"
|
||||
>
|
||||
<p class="text-body-1">{{ selectedRecommendation.reason }}</p>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 실행 단계 -->
|
||||
<div class="mb-4" v-if="selectedRecommendation.steps">
|
||||
<h4 class="text-h6 mb-2">📝 실행 단계</h4>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="(step, index) in selectedRecommendation.steps"
|
||||
:key="index"
|
||||
class="px-0"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-avatar
|
||||
size="32"
|
||||
color="primary"
|
||||
>
|
||||
<span class="white--text font-weight-bold">
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title>{{ step }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<!-- 예상 효과 -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-h6 mb-2">📈 예상 효과</h4>
|
||||
<v-chip
|
||||
v-for="effect in selectedRecommendation.expectedEffects"
|
||||
:key="effect"
|
||||
color="success"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ effect }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 관련 데이터 -->
|
||||
<div class="mb-4" v-if="selectedRecommendation.relatedData">
|
||||
<h4 class="text-h6 mb-2">📊 관련 데이터</h4>
|
||||
<v-card
|
||||
class="pa-3"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
>
|
||||
<div
|
||||
v-for="(data, key) in selectedRecommendation.relatedData"
|
||||
:key="key"
|
||||
class="d-flex justify-space-between mb-1"
|
||||
>
|
||||
<span>{{ key }}:</span>
|
||||
<strong>{{ data }}</strong>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 주의사항 -->
|
||||
<div v-if="selectedRecommendation.warnings">
|
||||
<h4 class="text-h6 mb-2">⚠️ 주의사항</h4>
|
||||
<v-alert
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
icon="mdi-alert"
|
||||
>
|
||||
<ul class="pl-4">
|
||||
<li
|
||||
v-for="warning in selectedRecommendation.warnings"
|
||||
:key="warning"
|
||||
>
|
||||
{{ warning }}
|
||||
</li>
|
||||
</ul>
|
||||
</v-alert>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn
|
||||
color="success"
|
||||
@click="applyRecommendation(selectedRecommendation)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-check</v-icon>
|
||||
추천 적용하기
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="saveRecommendation(selectedRecommendation)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-bookmark</v-icon>
|
||||
나중에 하기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 성공 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-snackbar>
|
||||
|
||||
<!-- 에러 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showError"
|
||||
color="error"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as recommendService from '@/services/recommend'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 상태 관리
|
||||
const selectedCategory = ref('')
|
||||
const todayRecommendation = ref(null)
|
||||
const categoryRecommendations = ref([])
|
||||
const selectedRecommendation = ref(null)
|
||||
|
||||
const generatingAll = ref(false)
|
||||
const generatingToday = ref(false)
|
||||
const generatingCategory = ref(false)
|
||||
const showDetailDialog = ref(false)
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const successMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 추천 카테고리 설정
|
||||
const recommendationCategories = ref([
|
||||
{
|
||||
id: 'content',
|
||||
title: '콘텐츠 마케팅',
|
||||
description: 'SNS 게시물 및 홍보 포스터 아이디어',
|
||||
icon: 'mdi-pencil-plus',
|
||||
color: 'purple',
|
||||
count: 5
|
||||
},
|
||||
{
|
||||
id: 'promotion',
|
||||
title: '프로모션 전략',
|
||||
description: '할인 이벤트 및 특가 제안',
|
||||
icon: 'mdi-sale',
|
||||
color: 'orange',
|
||||
count: 4
|
||||
},
|
||||
{
|
||||
id: 'menu',
|
||||
title: '메뉴 최적화',
|
||||
description: '인기 메뉴 분석 및 신메뉴 제안',
|
||||
icon: 'mdi-food',
|
||||
color: 'green',
|
||||
count: 6
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
title: '고객 관리',
|
||||
description: '고객 만족도 향상 방안',
|
||||
icon: 'mdi-account-heart',
|
||||
color: 'pink',
|
||||
count: 3
|
||||
},
|
||||
{
|
||||
id: 'timing',
|
||||
title: '타이밍 전략',
|
||||
description: '최적 홍보 시점 및 계절 마케팅',
|
||||
icon: 'mdi-clock',
|
||||
color: 'blue',
|
||||
count: 4
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
title: '데이터 분석',
|
||||
description: '매출 분석 및 트렌드 인사이트',
|
||||
icon: 'mdi-chart-line',
|
||||
color: 'teal',
|
||||
count: 5
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 현재 날짜 반환
|
||||
*/
|
||||
const getCurrentDate = () => {
|
||||
return new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 정보 반환
|
||||
*/
|
||||
const getCategoryInfo = (categoryId) => {
|
||||
return recommendationCategories.value.find(cat => cat.id === categoryId) || {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 우선순위 텍스트 반환
|
||||
*/
|
||||
const getPriorityText = (priority) => {
|
||||
const priorities = {
|
||||
high: '높음',
|
||||
medium: '보통',
|
||||
low: '낮음'
|
||||
}
|
||||
return priorities[priority] || priority
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 선택
|
||||
*/
|
||||
const selectCategory = async (categoryId) => {
|
||||
selectedCategory.value = categoryId
|
||||
await generateCategoryRecommendations(categoryId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘의 추천 생성
|
||||
*/
|
||||
const generateTodayRecommendation = async () => {
|
||||
generatingToday.value = true
|
||||
try {
|
||||
const response = await recommendService.generateMarketingTips({
|
||||
type: 'daily',
|
||||
includeWeather: true,
|
||||
includeTrends: true
|
||||
})
|
||||
|
||||
todayRecommendation.value = response
|
||||
} catch (error) {
|
||||
console.error('오늘의 추천 생성 실패:', error)
|
||||
// 샘플 데이터로 대체
|
||||
todayRecommendation.value = {
|
||||
title: '오늘은 날씨가 좋으니 테이크아웃 마케팅을 강화하세요!',
|
||||
content: '맑은 날씨로 인해 야외 활동이 증가할 것으로 예상됩니다. 테이크아웃 전용 할인이나 피크닉 세트 메뉴를 홍보하면 좋은 반응을 얻을 수 있을 것입니다.',
|
||||
tags: ['날씨연동', '테이크아웃', '야외활동', '피크닉']
|
||||
}
|
||||
} finally {
|
||||
generatingToday.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 추천 생성
|
||||
*/
|
||||
const generateCategoryRecommendations = async (categoryId) => {
|
||||
generatingCategory.value = true
|
||||
try {
|
||||
const response = await recommendService.generateMarketingTips({
|
||||
type: 'category',
|
||||
category: categoryId,
|
||||
limit: 6
|
||||
})
|
||||
|
||||
categoryRecommendations.value = response
|
||||
} catch (error) {
|
||||
console.error('카테고리 추천 생성 실패:', error)
|
||||
// 샘플 데이터로 대체
|
||||
categoryRecommendations.value = getSampleRecommendations(categoryId)
|
||||
} finally {
|
||||
generatingCategory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 추천 새로고침
|
||||
*/
|
||||
const generateAllRecommendations = async () => {
|
||||
generatingAll.value = true
|
||||
try {
|
||||
await generateTodayRecommendation()
|
||||
if (selectedCategory.value) {
|
||||
await generateCategoryRecommendations(selectedCategory.value)
|
||||
}
|
||||
successMessage.value = '모든 추천이 새로고침되었습니다.'
|
||||
showSuccess.value = true
|
||||
} catch (error) {
|
||||
console.error('전체 추천 새로고침 실패:', error)
|
||||
errorMessage.value = '추천 새로고침 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
} finally {
|
||||
generatingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 상세보기
|
||||
*/
|
||||
const viewRecommendationDetail = (recommendation) => {
|
||||
selectedRecommendation.value = recommendation
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 적용
|
||||
*/
|
||||
const applyRecommendation = async (recommendation) => {
|
||||
try {
|
||||
// 추천 타입에 따라 다른 페이지로 이동
|
||||
if (recommendation.type === 'content' || recommendation.title.includes('콘텐츠')) {
|
||||
router.push({ name: 'ContentCreation' })
|
||||
} else if (recommendation.type === 'menu' || recommendation.title.includes('메뉴')) {
|
||||
router.push({ name: 'MenuManagement' })
|
||||
} else {
|
||||
// 기본적으로 대시보드로 이동
|
||||
router.push({ name: 'Dashboard' })
|
||||
}
|
||||
|
||||
successMessage.value = '추천이 적용되었습니다!'
|
||||
showSuccess.value = true
|
||||
showDetailDialog.value = false
|
||||
} catch (error) {
|
||||
console.error('추천 적용 실패:', error)
|
||||
errorMessage.value = '추천 적용 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 저장
|
||||
*/
|
||||
const saveRecommendation = async (recommendation) => {
|
||||
try {
|
||||
// 실제로는 추천 저장 API 호출
|
||||
successMessage.value = '추천이 저장되었습니다. 나중에 확인해보세요.'
|
||||
showSuccess.value = true
|
||||
showDetailDialog.value = false
|
||||
} catch (error) {
|
||||
console.error('추천 저장 실패:', error)
|
||||
errorMessage.value = '추천 저장 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 추천 데이터 생성
|
||||
*/
|
||||
const getSampleRecommendations = (categoryId) => {
|
||||
const samples = {
|
||||
content: [
|
||||
{
|
||||
title: '인스타그램 스토리 활용 강화',
|
||||
description: '일상적인 요리 과정을 스토리로 공유하여 고객과의 친밀감을 높이세요',
|
||||
reason: '스토리는 24시간 후 사라지는 특성으로 인해 부담 없이 자주 업로드할 수 있고, 고객들의 참여도가 높습니다',
|
||||
priority: 'high',
|
||||
estimatedTime: '30분/일',
|
||||
expectedEffects: ['브랜드 인지도 상승', '고객 참여도 증가', '일상적 소통 증가'],
|
||||
steps: [
|
||||
'매일 요리 과정 1-2장 촬영',
|
||||
'간단한 텍스트와 함께 스토리 업로드',
|
||||
'고객 댓글에 적극적으로 반응',
|
||||
'주간 결산 스토리 하이라이트로 저장'
|
||||
],
|
||||
type: 'content'
|
||||
},
|
||||
{
|
||||
title: '고객 리뷰 기반 콘텐츠 제작',
|
||||
description: '긍정적인 고객 리뷰를 활용하여 신뢰도 높은 홍보 콘텐츠를 만드세요',
|
||||
reason: '실제 고객의 생생한 후기는 잠재 고객들에게 강한 신뢰감을 줍니다',
|
||||
priority: 'medium',
|
||||
estimatedTime: '1시간/주',
|
||||
expectedEffects: ['신뢰도 향상', '구매 의사결정 도움', 'WOM 효과'],
|
||||
type: 'content'
|
||||
}
|
||||
],
|
||||
promotion: [
|
||||
{
|
||||
title: '우천 시 특별 할인 이벤트',
|
||||
description: '비 오는 날 방문 고객에게 특별 할인 혜택을 제공하세요',
|
||||
reason: '비 오는 날은 외출을 꺼리는 고객들에게 방문 동기를 부여할 수 있습니다',
|
||||
priority: 'high',
|
||||
estimatedTime: '2시간',
|
||||
expectedEffects: ['비 오는 날 매출 증대', '고객 만족도 상승', '재방문 유도'],
|
||||
relatedData: {
|
||||
'우천 시 평균 매출 감소율': '-23%',
|
||||
'할인 이벤트 시 예상 증가율': '+15%',
|
||||
'타 업체 유사 이벤트 성공률': '78%'
|
||||
},
|
||||
type: 'promotion'
|
||||
}
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
title: '계절 한정 메뉴 개발',
|
||||
description: '여름철에 어울리는 시원한 메뉴나 음료를 개발해보세요',
|
||||
reason: '계절감 있는 메뉴는 고객들의 관심을 끌고 화제성을 만들 수 있습니다',
|
||||
priority: 'medium',
|
||||
estimatedTime: '1주일',
|
||||
expectedEffects: ['신규 고객 유입', '매출 다양성 확보', '브랜드 이미지 개선'],
|
||||
warnings: [
|
||||
'계절 메뉴는 재료 수급이 불안정할 수 있습니다',
|
||||
'초기 테스트 기간을 거쳐 고객 반응을 확인하세요'
|
||||
],
|
||||
type: 'menu'
|
||||
}
|
||||
],
|
||||
customer: [
|
||||
{
|
||||
title: '단골 고객 VIP 프로그램 운영',
|
||||
description: '자주 방문하는 고객들을 위한 특별 혜택 프로그램을 만드세요',
|
||||
reason: '단골 고객의 재방문율을 높이고 신규 고객의 단골화를 유도할 수 있습니다',
|
||||
priority: 'high',
|
||||
estimatedTime: '반나절',
|
||||
expectedEffects: ['고객 충성도 향상', '재방문율 증가', '구전 효과'],
|
||||
type: 'customer'
|
||||
}
|
||||
],
|
||||
timing: [
|
||||
{
|
||||
title: '점심시간 사전 예약 시스템',
|
||||
description: '바쁜 점심시간에 미리 주문받아 대기시간을 단축하세요',
|
||||
reason: '직장인들의 짧은 점심시간을 고려한 서비스로 고객 만족도를 높일 수 있습니다',
|
||||
priority: 'medium',
|
||||
estimatedTime: '3시간',
|
||||
expectedEffects: ['점심시간 매출 증대', '고객 만족도 상승', '운영 효율성 향상'],
|
||||
type: 'timing'
|
||||
}
|
||||
],
|
||||
analysis: [
|
||||
{
|
||||
title: '주간 매출 패턴 분석 리포트',
|
||||
description: '요일별, 시간대별 매출 데이터를 분석하여 운영 최적화 방안을 찾으세요',
|
||||
reason: '데이터 기반의 의사결정으로 효율적인 매장 운영이 가능합니다',
|
||||
priority: 'medium',
|
||||
estimatedTime: '2시간/주',
|
||||
expectedEffects: ['운영 효율성 향상', '재고 관리 최적화', '인력 배치 개선'],
|
||||
type: 'analysis'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return samples[categoryId] || []
|
||||
}
|
||||
|
||||
// 컴포넌트 마운트시 초기 데이터 로드
|
||||
onMounted(async () => {
|
||||
await generateTodayRecommendation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gradient-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.recommendation-category-card {
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.recommendation-category-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.recommendation-category-card.selected {
|
||||
border-color: #1976D2;
|
||||
background-color: #E3F2FD;
|
||||
}
|
||||
|
||||
.recommendation-item-card {
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.recommendation-item-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
590
src/views/ContentCreationView.vue
Normal file
590
src/views/ContentCreationView.vue
Normal file
@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-pencil-plus</v-icon>
|
||||
AI 콘텐츠 생성
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-stepper
|
||||
v-model="currentStep"
|
||||
:items="stepperItems"
|
||||
alt-labels
|
||||
>
|
||||
<!-- Step 1: 콘텐츠 타입 선택 -->
|
||||
<template v-slot:item.1>
|
||||
<v-card
|
||||
class="pa-4"
|
||||
flat
|
||||
>
|
||||
<h3 class="text-h6 mb-4">어떤 콘텐츠를 만들까요?</h3>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card
|
||||
:class="['content-type-card', { 'selected': contentData.type === 'SNS_POST' }]"
|
||||
@click="contentData.type = 'SNS_POST'"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="text-center pa-6">
|
||||
<v-icon size="48" color="pink" class="mb-3">mdi-instagram</v-icon>
|
||||
<h4 class="text-h6 mb-2">SNS 게시물</h4>
|
||||
<p class="text-body-2">인스타그램 & 블로그용 게시물</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card
|
||||
:class="['content-type-card', { 'selected': contentData.type === 'POSTER' }]"
|
||||
@click="contentData.type = 'POSTER'"
|
||||
hover
|
||||
>
|
||||
<v-card-text class="text-center pa-6">
|
||||
<v-icon size="48" color="purple" class="mb-3">mdi-image</v-icon>
|
||||
<h4 class="text-h6 mb-2">홍보 포스터</h4>
|
||||
<p class="text-body-2">매장 게시용 포스터</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 홍보 대상 선택 -->
|
||||
<template v-slot:item.2>
|
||||
<v-card class="pa-4" flat>
|
||||
<h3 class="text-h6 mb-4">무엇을 홍보할까요?</h3>
|
||||
|
||||
<v-radio-group v-model="contentData.target">
|
||||
<v-radio
|
||||
label="🍜 메뉴 홍보"
|
||||
value="menu"
|
||||
/>
|
||||
<v-radio
|
||||
label="🏪 매장 소개"
|
||||
value="store"
|
||||
/>
|
||||
<v-radio
|
||||
label="🎉 이벤트 홍보"
|
||||
value="event"
|
||||
/>
|
||||
</v-radio-group>
|
||||
|
||||
<!-- 메뉴 선택 -->
|
||||
<v-select
|
||||
v-if="contentData.target === 'menu'"
|
||||
v-model="contentData.selectedMenu"
|
||||
label="홍보할 메뉴 선택"
|
||||
variant="outlined"
|
||||
:items="menuOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- 이벤트 입력 -->
|
||||
<v-text-field
|
||||
v-if="contentData.target === 'event'"
|
||||
v-model="contentData.eventName"
|
||||
label="이벤트명"
|
||||
variant="outlined"
|
||||
placeholder="예: 신메뉴 출시 이벤트"
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: 이미지 업로드 -->
|
||||
<template v-slot:item.3>
|
||||
<v-card class="pa-4" flat>
|
||||
<h3 class="text-h6 mb-4">이미지를 업로드하세요</h3>
|
||||
|
||||
<v-file-input
|
||||
v-model="contentData.images"
|
||||
label="이미지 파일"
|
||||
variant="outlined"
|
||||
accept="image/*"
|
||||
multiple
|
||||
prepend-icon="mdi-camera"
|
||||
show-size
|
||||
chips
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- 이미지 미리보기 -->
|
||||
<v-row v-if="imagePreviewUrls.length > 0">
|
||||
<v-col
|
||||
v-for="(url, index) in imagePreviewUrls"
|
||||
:key="index"
|
||||
cols="6"
|
||||
md="3"
|
||||
>
|
||||
<v-card class="pa-2">
|
||||
<v-img
|
||||
:src="url"
|
||||
height="100"
|
||||
cover
|
||||
/>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<!-- Step 4: 세부 옵션 -->
|
||||
<template v-slot:item.4>
|
||||
<v-card class="pa-4" flat>
|
||||
<h3 class="text-h6 mb-4">세부 옵션을 설정하세요</h3>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="contentData.toneAndManner"
|
||||
label="톤앤매너"
|
||||
variant="outlined"
|
||||
:items="TONE_OPTIONS"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="contentData.emotionIntensity"
|
||||
label="감정 강도"
|
||||
variant="outlined"
|
||||
:items="EMOTION_INTENSITY"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="contentData.promotion"
|
||||
label="프로모션 정보"
|
||||
variant="outlined"
|
||||
:items="PROMOTION_OPTIONS"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- SNS 플랫폼 선택 (SNS 게시물인 경우만) -->
|
||||
<v-col
|
||||
v-if="contentData.type === 'SNS_POST'"
|
||||
cols="12" md="6"
|
||||
>
|
||||
<v-select
|
||||
v-model="contentData.platform"
|
||||
label="게시 플랫폼"
|
||||
variant="outlined"
|
||||
:items="platformOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 기간 설정 -->
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="contentData.startDate"
|
||||
label="홍보 시작일"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="contentData.endDate"
|
||||
label="홍보 종료일"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<!-- Step 5: 생성 결과 -->
|
||||
<template v-slot:item.5>
|
||||
<v-card class="pa-4" flat>
|
||||
<h3 class="text-h6 mb-4">생성된 콘텐츠</h3>
|
||||
|
||||
<v-card
|
||||
v-if="generatedContent"
|
||||
class="pa-4 mb-4"
|
||||
color="blue-grey-lighten-5"
|
||||
variant="tonal"
|
||||
>
|
||||
<h4 class="text-h6 mb-2">{{ generatedContent.title }}</h4>
|
||||
<div class="text-body-1 mb-3" style="white-space: pre-line;">
|
||||
{{ generatedContent.content }}
|
||||
</div>
|
||||
|
||||
<!-- 해시태그 (SNS인 경우) -->
|
||||
<div
|
||||
v-if="generatedContent.hashtags && generatedContent.hashtags.length > 0"
|
||||
class="mb-3"
|
||||
>
|
||||
<v-chip
|
||||
v-for="tag in generatedContent.hashtags"
|
||||
:key="tag"
|
||||
class="mr-1 mb-1"
|
||||
size="small"
|
||||
color="primary"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<!-- 로딩 상태 -->
|
||||
<v-card
|
||||
v-else-if="generating"
|
||||
class="pa-8 text-center"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
class="mb-4"
|
||||
/>
|
||||
<p class="text-body-1">AI가 콘텐츠를 생성하고 있습니다...</p>
|
||||
<p class="text-body-2 grey--text">잠시만 기다려주세요</p>
|
||||
</v-card>
|
||||
|
||||
<!-- 생성 실패 -->
|
||||
<v-card
|
||||
v-else-if="generationError"
|
||||
class="pa-6 text-center"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon size="48" color="error" class="mb-2">mdi-alert-circle</v-icon>
|
||||
<p class="text-body-1">콘텐츠 생성에 실패했습니다</p>
|
||||
<p class="text-body-2">{{ generationError }}</p>
|
||||
</v-card>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-stepper>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn
|
||||
v-if="currentStep > 1"
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
@click="currentStep--"
|
||||
>
|
||||
이전
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<!-- 다음/생성 버튼 -->
|
||||
<v-btn
|
||||
v-if="currentStep < 5"
|
||||
color="primary"
|
||||
:disabled="!canProceed"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ currentStep === 4 ? 'AI 생성하기' : '다음' }}
|
||||
</v-btn>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<v-btn
|
||||
v-if="currentStep === 5 && generatedContent"
|
||||
color="success"
|
||||
:loading="saving"
|
||||
@click="saveContent"
|
||||
>
|
||||
저장하기
|
||||
</v-btn>
|
||||
|
||||
<!-- 재생성 버튼 -->
|
||||
<v-btn
|
||||
v-if="currentStep === 5"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
:loading="generating"
|
||||
@click="regenerateContent"
|
||||
>
|
||||
다시 생성
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 성공 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
timeout="3000"
|
||||
>
|
||||
콘텐츠가 성공적으로 저장되었습니다!
|
||||
</v-snackbar>
|
||||
|
||||
<!-- 에러 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showError"
|
||||
color="error"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContentStore } from '@/store/content'
|
||||
import { useStoreStore } from '@/store/store'
|
||||
import { TONE_OPTIONS, EMOTION_INTENSITY, PROMOTION_OPTIONS, PLATFORMS } from '@/utils/constants'
|
||||
|
||||
const router = useRouter()
|
||||
const contentStore = useContentStore()
|
||||
const storeStore = useStoreStore()
|
||||
|
||||
// 스테퍼 설정
|
||||
const currentStep = ref(1)
|
||||
const stepperItems = [
|
||||
{ title: '타입 선택', value: 1 },
|
||||
{ title: '홍보 대상', value: 2 },
|
||||
{ title: '이미지 업로드', value: 3 },
|
||||
{ title: '세부 옵션', value: 4 },
|
||||
{ title: '생성 완료', value: 5 }
|
||||
]
|
||||
|
||||
// 콘텐츠 데이터
|
||||
const contentData = ref({
|
||||
type: '',
|
||||
target: '',
|
||||
selectedMenu: null,
|
||||
eventName: '',
|
||||
images: [],
|
||||
toneAndManner: '친근함',
|
||||
emotionIntensity: '보통',
|
||||
promotion: '없음',
|
||||
platform: 'INSTAGRAM',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
// 상태 관리
|
||||
const generating = ref(false)
|
||||
const saving = ref(false)
|
||||
const generatedContent = ref(null)
|
||||
const generationError = ref('')
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 이미지 미리보기
|
||||
const imagePreviewUrls = ref([])
|
||||
|
||||
// 계산된 속성
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
return !!contentData.value.type
|
||||
case 2:
|
||||
if (contentData.value.target === 'menu') {
|
||||
return !!contentData.value.selectedMenu
|
||||
} else if (contentData.value.target === 'event') {
|
||||
return !!contentData.value.eventName
|
||||
}
|
||||
return !!contentData.value.target
|
||||
case 3:
|
||||
return contentData.value.images.length > 0
|
||||
case 4:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
return storeStore.menus.map(menu => ({
|
||||
text: menu.menuName,
|
||||
value: menu.id
|
||||
}))
|
||||
})
|
||||
|
||||
const platformOptions = [
|
||||
{ text: '인스타그램', value: 'INSTAGRAM' },
|
||||
{ text: '네이버 블로그', value: 'NAVER_BLOG' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 다음 단계로 이동
|
||||
*/
|
||||
const nextStep = async () => {
|
||||
if (currentStep.value === 4) {
|
||||
await generateContent()
|
||||
} else {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 생성
|
||||
*/
|
||||
const generateContent = async () => {
|
||||
generating.value = true
|
||||
generationError.value = ''
|
||||
currentStep.value = 5
|
||||
|
||||
try {
|
||||
let response
|
||||
|
||||
if (contentData.value.type === 'SNS_POST') {
|
||||
response = await contentStore.generateSNSContent({
|
||||
target: contentData.value.target,
|
||||
selectedMenu: contentData.value.selectedMenu,
|
||||
eventName: contentData.value.eventName,
|
||||
images: contentData.value.images,
|
||||
toneAndManner: contentData.value.toneAndManner,
|
||||
emotionIntensity: contentData.value.emotionIntensity,
|
||||
promotion: contentData.value.promotion,
|
||||
platform: contentData.value.platform,
|
||||
startDate: contentData.value.startDate,
|
||||
endDate: contentData.value.endDate
|
||||
})
|
||||
} else {
|
||||
response = await contentStore.generatePosterContent({
|
||||
target: contentData.value.target,
|
||||
selectedMenu: contentData.value.selectedMenu,
|
||||
eventName: contentData.value.eventName,
|
||||
images: contentData.value.images,
|
||||
toneAndManner: contentData.value.toneAndManner,
|
||||
promotion: contentData.value.promotion,
|
||||
startDate: contentData.value.startDate,
|
||||
endDate: contentData.value.endDate
|
||||
})
|
||||
}
|
||||
|
||||
generatedContent.value = response
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 생성 실패:', error)
|
||||
generationError.value = '콘텐츠 생성 중 오류가 발생했습니다.'
|
||||
|
||||
// 샘플 데이터로 대체
|
||||
generatedContent.value = {
|
||||
title: '신메뉴 떡볶이 출시!',
|
||||
content: `🔥 새로운 맛의 떡볶이가 출시되었어요! 🔥
|
||||
|
||||
매콤달콤한 특제 소스로 만든 우리 매장만의 시그니처 떡볶이를 맛보세요!
|
||||
|
||||
신선한 떡과 정성스럽게 끓인 국물의 조화가 일품입니다.
|
||||
|
||||
지금 방문하시면 특별 할인가로 만나보실 수 있어요! ✨`,
|
||||
hashtags: ['#떡볶이', '#신메뉴', '#분식맛집', '#김사장님분식점', '#매운맛', '#달콤한맛']
|
||||
}
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 재생성
|
||||
*/
|
||||
const regenerateContent = async () => {
|
||||
generatedContent.value = null
|
||||
await generateContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 저장
|
||||
*/
|
||||
const saveContent = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
const saveData = {
|
||||
...contentData.value,
|
||||
...generatedContent.value
|
||||
}
|
||||
|
||||
if (contentData.value.type === 'SNS_POST') {
|
||||
await contentStore.saveSNSContent(saveData)
|
||||
} else {
|
||||
await contentStore.savePosterContent(saveData)
|
||||
}
|
||||
|
||||
showSuccess.value = true
|
||||
|
||||
// 잠시 후 콘텐츠 관리 페이지로 이동
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'ContentManagement' })
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 저장 실패:', error)
|
||||
errorMessage.value = '콘텐츠 저장 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 이미지 파일 변경 감지
|
||||
watch(() => contentData.value.images, (newImages) => {
|
||||
imagePreviewUrls.value = []
|
||||
|
||||
if (newImages && newImages.length > 0) {
|
||||
newImages.forEach(file => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
imagePreviewUrls.value.push(e.target.result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 컴포넌트 마운트시 데이터 로드
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await storeStore.fetchMenus()
|
||||
} catch (error) {
|
||||
console.error('메뉴 데이터 로드 실패:', error)
|
||||
}
|
||||
|
||||
// 기본 날짜 설정
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
contentData.value.startDate = today
|
||||
contentData.value.endDate = nextWeek
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-type-card {
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.content-type-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.content-type-card.selected {
|
||||
border-color: #1976D2;
|
||||
background-color: #E3F2FD;
|
||||
}
|
||||
</style>
|
||||
987
src/views/ContentManagementView.vue
Normal file
987
src/views/ContentManagementView.vue
Normal file
@ -0,0 +1,987 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 필터 섹션 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-filter</v-icon>
|
||||
필터 및 검색
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filters.contentType"
|
||||
label="콘텐츠 타입"
|
||||
variant="outlined"
|
||||
:items="contentTypeOptions"
|
||||
clearable
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filters.platform"
|
||||
label="플랫폼"
|
||||
variant="outlined"
|
||||
:items="platformOptions"
|
||||
clearable
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filters.status"
|
||||
label="상태"
|
||||
variant="outlined"
|
||||
:items="statusOptions"
|
||||
clearable
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filters.search"
|
||||
label="제목 검색"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="filters.startDate"
|
||||
label="시작일"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="filters.endDate"
|
||||
label="종료일"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-refresh</v-icon>
|
||||
필터 초기화
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 콘텐츠 목록 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-folder-multiple</v-icon>
|
||||
콘텐츠 목록
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="$router.push({ name: 'ContentCreation' })"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-plus</v-icon>
|
||||
새 콘텐츠
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- 선택된 항목 액션 -->
|
||||
<v-card-text
|
||||
v-if="selectedItems.length > 0"
|
||||
class="pa-4 bg-blue-grey-lighten-5"
|
||||
>
|
||||
<v-row align="center">
|
||||
<v-col>
|
||||
<span class="text-body-2">
|
||||
{{ selectedItems.length }}개 항목이 선택됨
|
||||
</span>
|
||||
</v-col>
|
||||
<v-col class="text-right">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="confirmBulkDelete"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-delete</v-icon>
|
||||
선택 삭제
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<!-- 콘텐츠 카드 리스트 -->
|
||||
<v-card-text class="pa-4">
|
||||
<v-row v-if="filteredContents.length > 0">
|
||||
<v-col
|
||||
v-for="content in paginatedContents"
|
||||
:key="content.id"
|
||||
cols="12"
|
||||
md="6"
|
||||
lg="4"
|
||||
>
|
||||
<v-card
|
||||
class="content-card"
|
||||
elevation="1"
|
||||
hover
|
||||
>
|
||||
<!-- 선택 체크박스 -->
|
||||
<div class="content-select">
|
||||
<v-checkbox
|
||||
v-model="selectedItems"
|
||||
:value="content.id"
|
||||
hide-details
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 콘텐츠 타입 뱃지 -->
|
||||
<v-chip
|
||||
class="content-type-badge"
|
||||
:color="getContentTypeColor(content.contentType)"
|
||||
size="small"
|
||||
>
|
||||
{{ getContentTypeText(content.contentType) }}
|
||||
</v-chip>
|
||||
|
||||
<!-- 플랫폼 아이콘 -->
|
||||
<div class="platform-icon">
|
||||
<v-icon
|
||||
:color="getPlatformColor(content.platform)"
|
||||
size="24"
|
||||
>
|
||||
{{ getPlatformIcon(content.platform) }}
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pb-2">
|
||||
<h4 class="text-h6 font-weight-bold mb-2 text-truncate">
|
||||
{{ content.title }}
|
||||
</h4>
|
||||
|
||||
<p class="text-body-2 grey--text mb-3 content-preview">
|
||||
{{ truncateText(content.content, 100) }}
|
||||
</p>
|
||||
|
||||
<!-- 해시태그 (SNS인 경우) -->
|
||||
<div
|
||||
v-if="content.hashtags && content.hashtags.length > 0"
|
||||
class="mb-3"
|
||||
>
|
||||
<v-chip
|
||||
v-for="tag in content.hashtags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
class="mr-1 mb-1"
|
||||
color="blue-grey"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
<span
|
||||
v-if="content.hashtags.length > 3"
|
||||
class="text-caption grey--text"
|
||||
>
|
||||
+{{ content.hashtags.length - 3 }}개 더
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 상태 및 생성일 -->
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<v-chip
|
||||
:color="getStatusColor(content.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ getStatusText(content.status) }}
|
||||
</v-chip>
|
||||
<span class="text-caption grey--text">
|
||||
{{ formatRelativeTime(content.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pt-0">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="viewContent(content)"
|
||||
>
|
||||
상세보기
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="editContent(content)"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="confirmDelete(content)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<v-card
|
||||
v-else
|
||||
class="pa-8 text-center"
|
||||
color="grey-lighten-4"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon size="64" color="grey" class="mb-4">mdi-file-document-outline</v-icon>
|
||||
<h3 class="text-h6 mb-2">콘텐츠가 없습니다</h3>
|
||||
<p class="text-body-2 grey--text mb-4">
|
||||
{{ filters.search || filters.contentType || filters.platform || filters.status
|
||||
? '검색 조건에 맞는 콘텐츠가 없습니다'
|
||||
: '아직 생성된 콘텐츠가 없습니다' }}
|
||||
</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="$router.push({ name: 'ContentCreation' })"
|
||||
>
|
||||
첫 콘텐츠 만들기
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<v-divider v-if="filteredContents.length > itemsPerPage" />
|
||||
<v-card-actions
|
||||
v-if="filteredContents.length > itemsPerPage"
|
||||
class="justify-center pa-4"
|
||||
>
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="5"
|
||||
color="primary"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 콘텐츠 상세 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDetailDialog"
|
||||
max-width="800"
|
||||
scrollable
|
||||
>
|
||||
<v-card v-if="selectedContent">
|
||||
<v-card-title class="text-h6">
|
||||
{{ selectedContent.title }}
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon
|
||||
@click="showDetailDialog = false"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- 메타 정보 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="6">
|
||||
<div class="text-caption grey--text">콘텐츠 타입</div>
|
||||
<v-chip
|
||||
:color="getContentTypeColor(selectedContent.contentType)"
|
||||
size="small"
|
||||
class="mt-1"
|
||||
>
|
||||
{{ getContentTypeText(selectedContent.contentType) }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption grey--text">플랫폼</div>
|
||||
<v-chip
|
||||
:color="getPlatformColor(selectedContent.platform)"
|
||||
size="small"
|
||||
class="mt-1"
|
||||
>
|
||||
<v-icon class="mr-1" size="16">
|
||||
{{ getPlatformIcon(selectedContent.platform) }}
|
||||
</v-icon>
|
||||
{{ getPlatformText(selectedContent.platform) }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption grey--text">상태</div>
|
||||
<v-chip
|
||||
:color="getStatusColor(selectedContent.status)"
|
||||
size="small"
|
||||
class="mt-1"
|
||||
>
|
||||
{{ getStatusText(selectedContent.status) }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption grey--text">생성일</div>
|
||||
<div class="text-body-2 mt-1">
|
||||
{{ formatDateTime(selectedContent.createdAt) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 콘텐츠 내용 -->
|
||||
<div class="mb-4">
|
||||
<div class="text-caption grey--text mb-2">콘텐츠</div>
|
||||
<v-card
|
||||
class="pa-4"
|
||||
color="blue-grey-lighten-5"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="text-body-1" style="white-space: pre-line;">
|
||||
{{ selectedContent.content }}
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 해시태그 -->
|
||||
<div
|
||||
v-if="selectedContent.hashtags && selectedContent.hashtags.length > 0"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="text-caption grey--text mb-2">해시태그</div>
|
||||
<v-chip
|
||||
v-for="tag in selectedContent.hashtags"
|
||||
:key="tag"
|
||||
class="mr-1 mb-1"
|
||||
size="small"
|
||||
color="primary"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 생성 조건 -->
|
||||
<div v-if="selectedContent.conditions">
|
||||
<div class="text-caption grey--text mb-2">생성 조건</div>
|
||||
<v-card
|
||||
class="pa-3"
|
||||
color="grey-lighten-4"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption">톤앤매너</div>
|
||||
<div class="text-body-2">{{ selectedContent.conditions.toneAndManner }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption">감정 강도</div>
|
||||
<div class="text-body-2">{{ selectedContent.conditions.emotionIntensity }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption">프로모션</div>
|
||||
<div class="text-body-2">{{ selectedContent.conditions.promotion }}</div>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<div class="text-caption">홍보 기간</div>
|
||||
<div class="text-body-2">
|
||||
{{ formatDate(selectedContent.conditions.startDate) }} ~
|
||||
{{ formatDate(selectedContent.conditions.endDate) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
@click="editContent(selectedContent)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-pencil</v-icon>
|
||||
수정
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="outlined"
|
||||
@click="duplicateContent(selectedContent)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-content-copy</v-icon>
|
||||
복사
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="error"
|
||||
@click="confirmDelete(selectedContent)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-delete</v-icon>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 수정 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showEditDialog"
|
||||
max-width="600"
|
||||
persistent
|
||||
>
|
||||
<v-card v-if="editingContent">
|
||||
<v-card-title class="text-h6">
|
||||
콘텐츠 수정
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="editForm" v-model="editFormValid">
|
||||
<v-text-field
|
||||
v-model="editingContent.title"
|
||||
label="제목"
|
||||
variant="outlined"
|
||||
:rules="[v => !!v || '제목을 입력해주세요']"
|
||||
required
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<v-textarea
|
||||
v-model="editingContent.content"
|
||||
label="내용"
|
||||
variant="outlined"
|
||||
:rules="[v => !!v || '내용을 입력해주세요']"
|
||||
required
|
||||
rows="6"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<!-- 해시태그 수정 (SNS인 경우) -->
|
||||
<v-combobox
|
||||
v-if="editingContent.contentType === 'SNS_POST'"
|
||||
v-model="editingContent.hashtags"
|
||||
label="해시태그"
|
||||
variant="outlined"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
hint="#을 제외하고 입력하세요"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="text"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="updating"
|
||||
:disabled="!editFormValid"
|
||||
@click="saveEdit"
|
||||
>
|
||||
저장
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 성공 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-snackbar>
|
||||
|
||||
<!-- 에러 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showError"
|
||||
color="error"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContentStore } from '@/store/content'
|
||||
import { formatRelativeTime, formatDate, formatDateTime } from '@/utils/formatters'
|
||||
import { CONTENT_TYPES, PLATFORMS, CONTENT_STATUS } from '@/utils/constants'
|
||||
|
||||
const router = useRouter()
|
||||
const contentStore = useContentStore()
|
||||
|
||||
// 상태 관리
|
||||
const filters = ref({
|
||||
contentType: '',
|
||||
platform: '',
|
||||
status: '',
|
||||
search: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
const selectedItems = ref([])
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(12)
|
||||
|
||||
const showDetailDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const selectedContent = ref(null)
|
||||
const editingContent = ref(null)
|
||||
const editForm = ref(null)
|
||||
const editFormValid = ref(false)
|
||||
const updating = ref(false)
|
||||
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const successMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 옵션 설정
|
||||
const contentTypeOptions = [
|
||||
{ title: 'SNS 게시물', value: 'SNS_POST' },
|
||||
{ title: '홍보 포스터', value: 'POSTER' }
|
||||
]
|
||||
|
||||
const platformOptions = [
|
||||
{ title: '인스타그램', value: 'INSTAGRAM' },
|
||||
{ title: '네이버 블로그', value: 'NAVER_BLOG' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ title: '임시저장', value: 'DRAFT' },
|
||||
{ title: '게시됨', value: 'PUBLISHED' },
|
||||
{ title: '보관됨', value: 'ARCHIVED' }
|
||||
]
|
||||
|
||||
// 계산된 속성
|
||||
const filteredContents = computed(() => {
|
||||
let contents = contentStore.contents
|
||||
|
||||
if (filters.value.contentType) {
|
||||
contents = contents.filter(c => c.contentType === filters.value.contentType)
|
||||
}
|
||||
|
||||
if (filters.value.platform) {
|
||||
contents = contents.filter(c => c.platform === filters.value.platform)
|
||||
}
|
||||
|
||||
if (filters.value.status) {
|
||||
contents = contents.filter(c => c.status === filters.value.status)
|
||||
}
|
||||
|
||||
if (filters.value.search) {
|
||||
const keyword = filters.value.search.toLowerCase()
|
||||
contents = contents.filter(c =>
|
||||
c.title.toLowerCase().includes(keyword) ||
|
||||
c.content.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
if (filters.value.startDate) {
|
||||
contents = contents.filter(c =>
|
||||
new Date(c.createdAt) >= new Date(filters.value.startDate)
|
||||
)
|
||||
}
|
||||
|
||||
if (filters.value.endDate) {
|
||||
contents = contents.filter(c =>
|
||||
new Date(c.createdAt) <= new Date(filters.value.endDate)
|
||||
)
|
||||
}
|
||||
|
||||
return contents.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
})
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(filteredContents.value.length / itemsPerPage.value)
|
||||
)
|
||||
|
||||
const paginatedContents = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
return filteredContents.value.slice(start, end)
|
||||
})
|
||||
|
||||
// 유틸리티 함수들
|
||||
const getContentTypeColor = (type) => {
|
||||
const colors = {
|
||||
'SNS_POST': 'pink',
|
||||
'POSTER': 'purple'
|
||||
}
|
||||
return colors[type] || 'grey'
|
||||
}
|
||||
|
||||
const getContentTypeText = (type) => {
|
||||
const texts = {
|
||||
'SNS_POST': 'SNS',
|
||||
'POSTER': '포스터'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
const getPlatformColor = (platform) => {
|
||||
const colors = {
|
||||
'INSTAGRAM': 'pink',
|
||||
'NAVER_BLOG': 'green'
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
const getPlatformIcon = (platform) => {
|
||||
const icons = {
|
||||
'INSTAGRAM': 'mdi-instagram',
|
||||
'NAVER_BLOG': 'mdi-blogger'
|
||||
}
|
||||
return icons[platform] || 'mdi-web'
|
||||
}
|
||||
|
||||
const getPlatformText = (platform) => {
|
||||
const texts = {
|
||||
'INSTAGRAM': '인스타그램',
|
||||
'NAVER_BLOG': '네이버 블로그'
|
||||
}
|
||||
return texts[platform] || platform
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'DRAFT': 'orange',
|
||||
'PUBLISHED': 'success',
|
||||
'ARCHIVED': 'grey'
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'DRAFT': '임시저장',
|
||||
'PUBLISHED': '게시됨',
|
||||
'ARCHIVED': '보관됨'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
const truncateText = (text, limit) => {
|
||||
if (!text) return ''
|
||||
return text.length > limit ? text.substring(0, limit) + '...' : text
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 적용
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 초기화
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
contentType: '',
|
||||
platform: '',
|
||||
status: '',
|
||||
search: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
}
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 상세 보기
|
||||
*/
|
||||
const viewContent = (content) => {
|
||||
selectedContent.value = content
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 수정
|
||||
*/
|
||||
const editContent = (content) => {
|
||||
editingContent.value = { ...content }
|
||||
showDetailDialog.value = false
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 취소
|
||||
*/
|
||||
const cancelEdit = () => {
|
||||
showEditDialog.value = false
|
||||
editingContent.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 저장
|
||||
*/
|
||||
const saveEdit = async () => {
|
||||
if (!editFormValid.value) return
|
||||
|
||||
updating.value = true
|
||||
try {
|
||||
await contentStore.updateContent(editingContent.value.id, editingContent.value)
|
||||
showEditDialog.value = false
|
||||
editingContent.value = null
|
||||
successMessage.value = '콘텐츠가 수정되었습니다.'
|
||||
showSuccess.value = true
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 수정 실패:', error)
|
||||
errorMessage.value = '콘텐츠 수정 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 복사
|
||||
*/
|
||||
const duplicateContent = async (content) => {
|
||||
try {
|
||||
const duplicatedContent = {
|
||||
...content,
|
||||
title: `${content.title} (복사본)`,
|
||||
status: 'DRAFT'
|
||||
}
|
||||
delete duplicatedContent.id
|
||||
|
||||
// 여기서는 샘플로 처리
|
||||
successMessage.value = '콘텐츠가 복사되었습니다.'
|
||||
showSuccess.value = true
|
||||
showDetailDialog.value = false
|
||||
|
||||
// 실제로는 새 콘텐츠 생성 API 호출
|
||||
// await contentStore.createContent(duplicatedContent)
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 복사 실패:', error)
|
||||
errorMessage.value = '콘텐츠 복사 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 확인
|
||||
*/
|
||||
const confirmDelete = async (content) => {
|
||||
const confirmed = await window.$confirm(
|
||||
'콘텐츠 삭제',
|
||||
`"${content.title}" 콘텐츠를 삭제하시겠습니까?`
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
await deleteContent(content.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 삭제 확인
|
||||
*/
|
||||
const confirmBulkDelete = async () => {
|
||||
const confirmed = await window.$confirm(
|
||||
'콘텐츠 삭제',
|
||||
`선택된 ${selectedItems.value.length}개의 콘텐츠를 삭제하시겠습니까?`
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
await bulkDeleteContents()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 삭제
|
||||
*/
|
||||
const deleteContent = async (contentId) => {
|
||||
try {
|
||||
await contentStore.deleteContent(contentId)
|
||||
successMessage.value = '콘텐츠가 삭제되었습니다.'
|
||||
showSuccess.value = true
|
||||
showDetailDialog.value = false
|
||||
|
||||
// 선택 목록에서 제거
|
||||
selectedItems.value = selectedItems.value.filter(id => id !== contentId)
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 삭제 실패:', error)
|
||||
errorMessage.value = '콘텐츠 삭제 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 삭제
|
||||
*/
|
||||
const bulkDeleteContents = async () => {
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedItems.value.map(id => contentStore.deleteContent(id))
|
||||
)
|
||||
successMessage.value = `${selectedItems.value.length}개의 콘텐츠가 삭제되었습니다.`
|
||||
showSuccess.value = true
|
||||
selectedItems.value = []
|
||||
} catch (error) {
|
||||
console.error('대량 삭제 실패:', error)
|
||||
errorMessage.value = '콘텐츠 삭제 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 마운트시 데이터 로드
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await contentStore.fetchContents()
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 로드 실패:', error)
|
||||
// 샘플 데이터로 대체
|
||||
contentStore.contents = [
|
||||
{
|
||||
id: 1,
|
||||
title: '신메뉴 떡볶이 출시!',
|
||||
content: '🔥 새로운 맛의 떡볶이가 출시되었어요! 🔥\n\n매콤달콤한 특제 소스로 만든 우리 매장만의 시그니처 떡볶이를 맛보세요!',
|
||||
contentType: 'SNS_POST',
|
||||
platform: 'INSTAGRAM',
|
||||
status: 'PUBLISHED',
|
||||
hashtags: ['#떡볶이', '#신메뉴', '#분식맛집'],
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2시간 전
|
||||
conditions: {
|
||||
toneAndManner: '친근함',
|
||||
emotionIntensity: '보통',
|
||||
promotion: '신메뉴 알림',
|
||||
startDate: '2024-06-10',
|
||||
endDate: '2024-06-17'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '주말 특가 이벤트',
|
||||
content: '주말을 맞아 준비한 특별한 할인 이벤트!\n\n김밥 + 떡볶이 세트를 특가로 만나보세요.',
|
||||
contentType: 'SNS_POST',
|
||||
platform: 'NAVER_BLOG',
|
||||
status: 'PUBLISHED',
|
||||
hashtags: ['#주말특가', '#할인이벤트', '#세트메뉴'],
|
||||
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), // 1일 전
|
||||
conditions: {
|
||||
toneAndManner: '전문적',
|
||||
emotionIntensity: '열정적',
|
||||
promotion: '할인 정보',
|
||||
startDate: '2024-06-08',
|
||||
endDate: '2024-06-16'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '봄맞이 할인 포스터',
|
||||
content: '봄맞이 특별 할인 이벤트 포스터',
|
||||
contentType: 'POSTER',
|
||||
platform: null,
|
||||
status: 'DRAFT',
|
||||
hashtags: null,
|
||||
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // 3일 전
|
||||
conditions: {
|
||||
toneAndManner: '고급스러운',
|
||||
emotionIntensity: '차분함',
|
||||
promotion: '할인 정보',
|
||||
startDate: '2024-06-07',
|
||||
endDate: '2024-06-21'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-card {
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.content-select {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.content-type-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
763
src/views/SalesAnalysisView.vue
Normal file
763
src/views/SalesAnalysisView.vue
Normal file
@ -0,0 +1,763 @@
|
||||
//* src/views/SalesAnalysisView.vue
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 기간 선택 및 필터 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-chart-line</v-icon>
|
||||
매출 분석
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="exportReport"
|
||||
:loading="exporting"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-download</v-icon>
|
||||
보고서 다운로드
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-row>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="selectedPeriod"
|
||||
label="분석 기간"
|
||||
variant="outlined"
|
||||
:items="periodOptions"
|
||||
@update:model-value="loadSalesData"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="customStartDate"
|
||||
label="시작일"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
:disabled="selectedPeriod !== 'custom'"
|
||||
@update:model-value="loadSalesData"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="customEndDate"
|
||||
label="종료일"
|
||||
variant="outlined"
|
||||
type="date"
|
||||
:disabled="selectedPeriod !== 'custom'"
|
||||
@update:model-value="loadSalesData"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="selectedCategory"
|
||||
label="메뉴 카테고리"
|
||||
variant="outlined"
|
||||
:items="categoryOptions"
|
||||
clearable
|
||||
@update:model-value="loadSalesData"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 매출 요약 카드 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="6" md="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<v-icon size="32" color="success" class="mb-2">mdi-currency-krw</v-icon>
|
||||
<div class="text-h5 font-weight-bold">{{ formatCurrency(salesSummary.totalSales) }}</div>
|
||||
<div class="text-body-2 grey--text">총 매출</div>
|
||||
<v-chip
|
||||
:color="salesSummary.salesChange >= 0 ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ salesSummary.salesChange >= 0 ? '+' : '' }}{{ salesSummary.salesChange }}%
|
||||
</v-chip>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" md="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<v-icon size="32" color="info" class="mb-2">mdi-cart</v-icon>
|
||||
<div class="text-h5 font-weight-bold">{{ salesSummary.totalOrders.toLocaleString() }}</div>
|
||||
<div class="text-body-2 grey--text">총 주문 수</div>
|
||||
<v-chip
|
||||
:color="salesSummary.ordersChange >= 0 ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ salesSummary.ordersChange >= 0 ? '+' : '' }}{{ salesSummary.ordersChange }}%
|
||||
</v-chip>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" md="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<v-icon size="32" color="warning" class="mb-2">mdi-calculator</v-icon>
|
||||
<div class="text-h5 font-weight-bold">{{ formatCurrency(salesSummary.averageOrder) }}</div>
|
||||
<div class="text-body-2 grey--text">평균 주문금액</div>
|
||||
<v-chip
|
||||
:color="salesSummary.averageChange >= 0 ? 'success' : 'error'"
|
||||
size="small"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ salesSummary.averageChange >= 0 ? '+' : '' }}{{ salesSummary.averageChange }}%
|
||||
</v-chip>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" md="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<v-icon size="32" color="purple" class="mb-2">mdi-star</v-icon>
|
||||
<div class="text-h5 font-weight-bold">{{ salesSummary.topMenuItem }}</div>
|
||||
<div class="text-body-2 grey--text">최고 인기 메뉴</div>
|
||||
<div class="text-caption">{{ formatCurrency(salesSummary.topMenuSales) }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 차트 섹션 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" lg="8">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
매출 추이
|
||||
<v-spacer />
|
||||
<v-btn-toggle
|
||||
v-model="chartType"
|
||||
mandatory
|
||||
size="small"
|
||||
@update:model-value="updateChart"
|
||||
>
|
||||
<v-btn value="daily">일별</v-btn>
|
||||
<v-btn value="weekly">주별</v-btn>
|
||||
<v-btn value="monthly">월별</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<div class="chart-container">
|
||||
<canvas ref="salesChart" height="400"></canvas>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" lg="4">
|
||||
<v-card elevation="2" class="mb-4">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
메뉴별 매출 비율
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<div class="chart-container">
|
||||
<canvas ref="menuChart" height="300"></canvas>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 상세 분석 테이블 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" lg="6">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
메뉴별 매출 순위
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>순위</th>
|
||||
<th>메뉴명</th>
|
||||
<th>매출액</th>
|
||||
<th>주문수</th>
|
||||
<th>비율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in menuRanking"
|
||||
:key="item.menuName"
|
||||
>
|
||||
<td>
|
||||
<v-chip
|
||||
:color="index < 3 ? ['error', 'warning', 'success'][index] : 'grey'"
|
||||
size="small"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="font-weight-bold">{{ item.menuName }}</td>
|
||||
<td>{{ formatCurrency(item.sales) }}</td>
|
||||
<td>{{ item.orders.toLocaleString() }}회</td>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<v-progress-linear
|
||||
:model-value="item.percentage"
|
||||
color="primary"
|
||||
height="6"
|
||||
class="mr-2"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
{{ item.percentage }}%
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" lg="6">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
시간대별 매출 분석
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간대</th>
|
||||
<th>매출액</th>
|
||||
<th>주문수</th>
|
||||
<th>비율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in timeSlotAnalysis"
|
||||
:key="item.timeSlot"
|
||||
>
|
||||
<td class="font-weight-bold">{{ item.timeSlot }}</td>
|
||||
<td>{{ formatCurrency(item.sales) }}</td>
|
||||
<td>{{ item.orders.toLocaleString() }}회</td>
|
||||
<td>
|
||||
<div class="d-flex align-center">
|
||||
<v-progress-linear
|
||||
:model-value="item.percentage"
|
||||
color="success"
|
||||
height="6"
|
||||
class="mr-2"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
{{ item.percentage }}%
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 인사이트 및 제안 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="info">mdi-lightbulb</v-icon>
|
||||
AI 매출 분석 인사이트
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="insight in salesInsights"
|
||||
:key="insight.id"
|
||||
cols="12"
|
||||
md="4"
|
||||
>
|
||||
<v-card
|
||||
:color="insight.type === 'positive' ? 'success' : insight.type === 'warning' ? 'warning' : 'info'"
|
||||
variant="tonal"
|
||||
class="pa-4"
|
||||
>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon
|
||||
:color="insight.type === 'positive' ? 'success' : insight.type === 'warning' ? 'warning' : 'info'"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ insight.icon }}
|
||||
</v-icon>
|
||||
<h4 class="text-h6 font-weight-bold">{{ insight.title }}</h4>
|
||||
</div>
|
||||
<p class="text-body-2 mb-3">{{ insight.description }}</p>
|
||||
<v-btn
|
||||
:color="insight.type === 'positive' ? 'success' : insight.type === 'warning' ? 'warning' : 'info'"
|
||||
size="small"
|
||||
@click="viewInsightDetail(insight)"
|
||||
>
|
||||
자세히 보기
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 인사이트 상세 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showInsightDialog"
|
||||
max-width="600"
|
||||
>
|
||||
<v-card v-if="selectedInsight">
|
||||
<v-card-title class="text-h6">
|
||||
<v-icon class="mr-2" :color="selectedInsight.type === 'positive' ? 'success' : selectedInsight.type === 'warning' ? 'warning' : 'info'">
|
||||
{{ selectedInsight.icon }}
|
||||
</v-icon>
|
||||
{{ selectedInsight.title }}
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon
|
||||
@click="showInsightDialog = false"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<p class="text-body-1 mb-4">{{ selectedInsight.description }}</p>
|
||||
|
||||
<div v-if="selectedInsight.details" class="mb-4">
|
||||
<h4 class="text-h6 mb-2">상세 분석</h4>
|
||||
<v-card class="pa-3" color="blue-grey-lighten-5" variant="tonal">
|
||||
<p class="text-body-2">{{ selectedInsight.details }}</p>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedInsight.recommendations">
|
||||
<h4 class="text-h6 mb-2">개선 제안</h4>
|
||||
<v-list class="pa-0">
|
||||
<v-list-item
|
||||
v-for="(rec, index) in selectedInsight.recommendations"
|
||||
:key="index"
|
||||
class="px-0"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="primary" class="mr-2">mdi-check-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ rec }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="applyInsightRecommendation(selectedInsight)"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-check</v-icon>
|
||||
제안 적용하기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 성공 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-snackbar>
|
||||
|
||||
<!-- 에러 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="showError"
|
||||
color="error"
|
||||
timeout="3000"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { formatCurrency } from '@/utils/formatters'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
// 상태 관리
|
||||
const selectedPeriod = ref('week')
|
||||
const customStartDate = ref('')
|
||||
const customEndDate = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const chartType = ref('daily')
|
||||
const exporting = ref(false)
|
||||
const showInsightDialog = ref(false)
|
||||
const selectedInsight = ref(null)
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const successMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 차트 참조
|
||||
const salesChart = ref(null)
|
||||
const menuChart = ref(null)
|
||||
let salesChartInstance = null
|
||||
let menuChartInstance = null
|
||||
|
||||
// 옵션 설정
|
||||
const periodOptions = [
|
||||
{ title: '오늘', value: 'today' },
|
||||
{ title: '이번 주', value: 'week' },
|
||||
{ title: '이번 달', value: 'month' },
|
||||
{ title: '지난 3개월', value: 'quarter' },
|
||||
{ title: '직접 선택', value: 'custom' }
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
{ title: '전체', value: '' },
|
||||
{ title: '면류', value: '면류' },
|
||||
{ title: '튀김', value: '튀김' },
|
||||
{ title: '음료', value: '음료' },
|
||||
{ title: '기타', value: '기타' }
|
||||
]
|
||||
|
||||
// 매출 요약 데이터
|
||||
const salesSummary = ref({
|
||||
totalSales: 2450000,
|
||||
salesChange: 8.2,
|
||||
totalOrders: 1234,
|
||||
ordersChange: 5.7,
|
||||
averageOrder: 19900,
|
||||
averageChange: 2.3,
|
||||
topMenuItem: '떡볶이',
|
||||
topMenuSales: 980000
|
||||
})
|
||||
|
||||
// 메뉴별 매출 순위
|
||||
const menuRanking = ref([
|
||||
{
|
||||
menuName: '떡볶이',
|
||||
sales: 980000,
|
||||
orders: 327,
|
||||
percentage: 40
|
||||
},
|
||||
{
|
||||
menuName: '김밥',
|
||||
sales: 735000,
|
||||
orders: 294,
|
||||
percentage: 30
|
||||
},
|
||||
{
|
||||
menuName: '오징어튀김',
|
||||
sales: 490000,
|
||||
orders: 123,
|
||||
percentage: 20
|
||||
},
|
||||
{
|
||||
menuName: '콜라',
|
||||
sales: 245000,
|
||||
orders: 163,
|
||||
percentage: 10
|
||||
}
|
||||
])
|
||||
|
||||
// 시간대별 매출 분석
|
||||
const timeSlotAnalysis = ref([
|
||||
{
|
||||
timeSlot: '11:00-13:00',
|
||||
sales: 980000,
|
||||
orders: 456,
|
||||
percentage: 45
|
||||
},
|
||||
{
|
||||
timeSlot: '17:00-19:00',
|
||||
sales: 735000,
|
||||
orders: 342,
|
||||
percentage: 35
|
||||
},
|
||||
{
|
||||
timeSlot: '13:00-17:00',
|
||||
sales: 294000,
|
||||
orders: 189,
|
||||
percentage: 15
|
||||
},
|
||||
{
|
||||
timeSlot: '19:00-21:00',
|
||||
sales: 147000,
|
||||
orders: 67,
|
||||
percentage: 5
|
||||
}
|
||||
])
|
||||
|
||||
// AI 인사이트
|
||||
const salesInsights = ref([
|
||||
{
|
||||
id: 1,
|
||||
type: 'positive',
|
||||
icon: 'mdi-trending-up',
|
||||
title: '매출 성장세',
|
||||
description: '지난 주 대비 매출이 8.2% 증가했습니다. 특히 떡볶이 매출이 크게 늘었습니다.',
|
||||
details: '떡볶이의 경우 SNS 마케팅 효과로 인해 젊은 고객층의 주문이 증가했습니다. 인스타그램 게시물 조회수가 1,234회를 기록하며 브랜드 인지도가 상승했습니다.',
|
||||
recommendations: [
|
||||
'떡볶이 관련 추가 콘텐츠 제작',
|
||||
'떡볶이 + 김밥 세트 메뉴 홍보',
|
||||
'SNS 마케팅 예산 확대 고려'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'warning',
|
||||
icon: 'mdi-clock-alert',
|
||||
title: '오후 시간대 매출 저조',
|
||||
description: '13-17시 시간대 매출이 전체의 15%에 그치고 있습니다.',
|
||||
details: '점심시간 이후 저녁시간 전까지의 매출이 상대적으로 낮습니다. 이 시간대는 주로 학생들과 재택근무자들이 주요 고객층입니다.',
|
||||
recommendations: [
|
||||
'오후 특가 메뉴 운영',
|
||||
'학생 할인 이벤트 진행',
|
||||
'간식류 메뉴 개발 및 홍보'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'info',
|
||||
icon: 'mdi-chart-bar',
|
||||
title: '메뉴 다양성 확대 기회',
|
||||
description: '상위 2개 메뉴가 전체 매출의 70%를 차지하고 있습니다.',
|
||||
details: '떡볶이와 김밥에 대한 의존도가 높아 메뉴 포트폴리오 다양화가 필요합니다. 새로운 메뉴 개발로 매출 안정성을 높일 수 있습니다.',
|
||||
recommendations: [
|
||||
'신메뉴 개발 및 테스트 마케팅',
|
||||
'계절 한정 메뉴 출시',
|
||||
'고객 설문을 통한 니즈 파악'
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 매출 데이터 로드
|
||||
*/
|
||||
const loadSalesData = async () => {
|
||||
try {
|
||||
// 실제로는 API 호출
|
||||
await updateChart()
|
||||
} catch (error) {
|
||||
console.error('매출 데이터 로드 실패:', error)
|
||||
errorMessage.value = '매출 데이터를 불러오는 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 업데이트
|
||||
*/
|
||||
const updateChart = async () => {
|
||||
await nextTick()
|
||||
|
||||
// 매출 추이 차트
|
||||
if (salesChartInstance) {
|
||||
salesChartInstance.destroy()
|
||||
}
|
||||
|
||||
if (salesChart.value) {
|
||||
const ctx = salesChart.value.getContext('2d')
|
||||
salesChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: getSalesChartLabels(),
|
||||
datasets: [
|
||||
{
|
||||
label: '매출액',
|
||||
data: getSalesChartData(),
|
||||
borderColor: '#1976D2',
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '전년 동기',
|
||||
data: getComparisonData(),
|
||||
borderColor: '#FF9800',
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top'
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return formatCurrency(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 메뉴별 매출 차트
|
||||
if (menuChartInstance) {
|
||||
menuChartInstance.destroy()
|
||||
}
|
||||
|
||||
if (menuChart.value) {
|
||||
const ctx2 = menuChart.value.getContext('2d')
|
||||
menuChartInstance = new Chart(ctx2, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: menuRanking.value.map(item => item.menuName),
|
||||
datasets: [{
|
||||
data: menuRanking.value.map(item => item.sales),
|
||||
backgroundColor: [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 라벨 생성
|
||||
*/
|
||||
const getSalesChartLabels = () => {
|
||||
if (chartType.value === 'daily') {
|
||||
return ['월', '화', '수', '목', '금', '토', '일']
|
||||
} else if (chartType.value === 'weekly') {
|
||||
return ['1주차', '2주차', '3주차', '4주차']
|
||||
} else {
|
||||
return ['1월', '2월', '3월', '4월', '5월', '6월']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 데이터 생성
|
||||
*/
|
||||
const getSalesChartData = () => {
|
||||
if (chartType.value === 'daily') {
|
||||
return [120000, 135000, 118000, 145000, 160000, 180000, 155000]
|
||||
} else if (chartType.value === 'weekly') {
|
||||
return [580000, 620000, 595000, 655000]
|
||||
} else {
|
||||
return [2200000, 2350000, 2180000, 2420000, 2380000, 2450000]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비교 데이터 생성
|
||||
*/
|
||||
const getComparisonData = () => {
|
||||
if (chartType.value === 'daily') {
|
||||
return [110000, 125000, 108000, 135000, 150000, 165000, 145000]
|
||||
} else if (chartType.value === 'weekly') {
|
||||
return [520000, 560000, 535000, 590000]
|
||||
} else {
|
||||
return [2000000, 2150000, 1980000, 2200000, 2160000, 2250000]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인사이트 상세보기
|
||||
*/
|
||||
const viewInsightDetail = (insight) => {
|
||||
selectedInsight.value = insight
|
||||
showInsightDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 인사이트 제안 적용
|
||||
*/
|
||||
const applyInsightRecommendation = (insight) => {
|
||||
successMessage.value = `"${insight.title}" 제안이 적용되었습니다.`
|
||||
showSuccess.value = true
|
||||
showInsightDialog.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 보고서 내보내기
|
||||
*/
|
||||
const exportReport = async () => {
|
||||
exporting.value = true
|
||||
try {
|
||||
// 실제로는 보고서 생성 API 호출
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
successMessage.value = '매출 분석 보고서가 다운로드되었습니다.'
|
||||
showSuccess.value = true
|
||||
} catch (error) {
|
||||
console.error('보고서 내보내기 실패:', error)
|
||||
errorMessage.value = '보고서 내보내기 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 마운트시 데이터 로드
|
||||
onMounted(async () => {
|
||||
await loadSalesData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user