commit
da0c78cce6
BIN
public/images/menu-placeholder.png
Normal file
BIN
public/images/menu-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
407
src/components/poster/PosterForm.vue
Normal file
407
src/components/poster/PosterForm.vue
Normal file
@ -0,0 +1,407 @@
|
||||
//* src/components/poster/PosterForm.vue
|
||||
<template>
|
||||
<v-form ref="form" v-model="valid" @submit.prevent="handleGenerate">
|
||||
<!-- 기본 정보 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-h6 mb-4">
|
||||
<v-icon class="mr-2">mdi-information</v-icon>
|
||||
기본 정보
|
||||
</h3>
|
||||
|
||||
<v-text-field
|
||||
v-model="formData.title"
|
||||
label="포스터 제목"
|
||||
placeholder="예: 신메뉴 출시 이벤트"
|
||||
:rules="titleRules"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="formData.targetAudience"
|
||||
label="홍보 대상"
|
||||
:items="targetAudienceOptions"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-textarea
|
||||
v-model="formData.requirement"
|
||||
label="구체적인 요구사항"
|
||||
placeholder="어떤 포스터를 원하시는지 자세히 설명해주세요"
|
||||
rows="3"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 프로모션 정보 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-h6 mb-4">
|
||||
<v-icon class="mr-2">mdi-calendar-range</v-icon>
|
||||
프로모션 정보
|
||||
</h3>
|
||||
|
||||
<v-text-field
|
||||
v-model="formData.eventName"
|
||||
label="이벤트명"
|
||||
placeholder="예: 신메뉴 출시 기념 이벤트"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="formData.promotionStartDate"
|
||||
label="홍보 시작일"
|
||||
type="datetime-local"
|
||||
variant="outlined"
|
||||
:rules="startDateRules"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="formData.promotionEndDate"
|
||||
label="홍보 종료일"
|
||||
type="datetime-local"
|
||||
variant="outlined"
|
||||
:rules="endDateRules"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="formData.startDate"
|
||||
label="이벤트 시작일"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="formData.endDate"
|
||||
label="이벤트 종료일"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 디자인 설정 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-h6 mb-4">
|
||||
<v-icon class="mr-2">mdi-palette</v-icon>
|
||||
디자인 설정
|
||||
</h3>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="formData.imageStyle"
|
||||
label="이미지 스타일"
|
||||
:items="imageStyleOptions"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="formData.photoStyle"
|
||||
label="사진 스타일"
|
||||
:items="photoStyleOptions"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="formData.promotionType"
|
||||
label="프로모션 유형"
|
||||
:items="promotionTypeOptions"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="formData.emotionIntensity"
|
||||
label="감정 강도"
|
||||
:items="emotionIntensityOptions"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-select
|
||||
v-model="formData.toneAndManner"
|
||||
label="톤앤매너"
|
||||
:items="toneAndMannerOptions"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="formData.category"
|
||||
label="카테고리"
|
||||
:items="categoryOptions"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 이미지 업로드 -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-h6 mb-4">
|
||||
<v-icon class="mr-2">mdi-image-multiple</v-icon>
|
||||
이미지 업로드
|
||||
</h3>
|
||||
|
||||
<v-file-input
|
||||
v-model="uploadedFiles"
|
||||
label="이미지 선택"
|
||||
placeholder="포스터에 포함할 이미지를 선택하세요"
|
||||
accept="image/*"
|
||||
multiple
|
||||
show-size
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-camera"
|
||||
@change="handleFileUpload"
|
||||
:rules="imageRules"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- 업로드된 이미지 미리보기 -->
|
||||
<div v-if="formData.images.length > 0" class="mb-4">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="(image, index) in formData.images"
|
||||
:key="index"
|
||||
cols="6"
|
||||
sm="4"
|
||||
md="3"
|
||||
>
|
||||
<v-card class="elevation-2">
|
||||
<v-img
|
||||
:src="image"
|
||||
height="120"
|
||||
cover
|
||||
/>
|
||||
<v-card-actions class="pa-2">
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="removeImage(index)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="d-flex gap-3">
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
variant="elevated"
|
||||
:loading="loading"
|
||||
:disabled="!valid || formData.images.length === 0"
|
||||
@click="handleGenerate"
|
||||
class="flex-grow-1"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-magic-staff</v-icon>
|
||||
AI 포스터 생성
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="success"
|
||||
size="large"
|
||||
variant="elevated"
|
||||
:disabled="!canSave"
|
||||
@click="handleSave"
|
||||
class="flex-grow-1"
|
||||
>
|
||||
<v-icon class="mr-2">mdi-content-save</v-icon>
|
||||
포스터 저장
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
/**
|
||||
* 포스터 생성 폼 컴포넌트
|
||||
* - 포스터 정보 입력
|
||||
* - 이미지 업로드
|
||||
* - 폼 유효성 검증
|
||||
*/
|
||||
|
||||
// Props 정의
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// Emits 정의
|
||||
const emit = defineEmits(['update:modelValue', 'generate', 'save'])
|
||||
|
||||
// 반응형 데이터
|
||||
const form = ref(null)
|
||||
const valid = ref(false)
|
||||
const uploadedFiles = ref([])
|
||||
|
||||
// 컴퓨티드 속성
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return formData.value.title && formData.value.images.length > 0
|
||||
})
|
||||
|
||||
// 옵션 데이터
|
||||
const targetAudienceOptions = [
|
||||
'메뉴', '이벤트', '매장', '서비스', '할인혜택'
|
||||
]
|
||||
|
||||
const imageStyleOptions = [
|
||||
'모던', '클래식', '미니멀', '화려한', '자연스러운', '빈티지'
|
||||
]
|
||||
|
||||
const photoStyleOptions = [
|
||||
'밝고 화사한', '따뜻한 톤', '차분한 톤', '선명하고 대비가 강한', '부드러운 톤', '시크한 톤'
|
||||
]
|
||||
|
||||
const promotionTypeOptions = [
|
||||
'할인 정보', '신메뉴 소개', '이벤트 안내', '매장 홍보', '서비스 안내'
|
||||
]
|
||||
|
||||
const emotionIntensityOptions = [
|
||||
'약함', '보통', '강함', '매우 강함'
|
||||
]
|
||||
|
||||
const toneAndMannerOptions = [
|
||||
'전문적', '친근한', '활기찬', '고급스러운', '캐주얼한', '신뢰감 있는'
|
||||
]
|
||||
|
||||
const categoryOptions = [
|
||||
'이벤트', '메뉴', '할인', '공지', '홍보'
|
||||
]
|
||||
|
||||
// 유효성 검증 규칙
|
||||
const titleRules = [
|
||||
v => !!v || '제목은 필수입니다',
|
||||
v => (v && v.length <= 100) || '제목은 100자 이하로 입력해주세요'
|
||||
]
|
||||
|
||||
const startDateRules = [
|
||||
v => !!v || '홍보 시작일은 필수입니다'
|
||||
]
|
||||
|
||||
const endDateRules = [
|
||||
v => !!v || '홍보 종료일은 필수입니다',
|
||||
v => {
|
||||
if (!v || !formData.value.promotionStartDate) return true
|
||||
return new Date(v) > new Date(formData.value.promotionStartDate) || '종료일은 시작일보다 늦어야 합니다'
|
||||
}
|
||||
]
|
||||
|
||||
const imageRules = [
|
||||
v => formData.value.images.length > 0 || '최소 1개의 이미지를 업로드해야 합니다'
|
||||
]
|
||||
|
||||
/**
|
||||
* 파일 업로드 처리 - 오류 수정된 버전
|
||||
*/
|
||||
const handleFileUpload = (files) => {
|
||||
console.log('📁 파일 업로드 시작:', files)
|
||||
|
||||
// 파일이 없는 경우 처리
|
||||
if (!files || files.length === 0) {
|
||||
console.log('파일이 선택되지 않음')
|
||||
return
|
||||
}
|
||||
|
||||
// FileList를 배열로 변환
|
||||
const fileArray = Array.from(files)
|
||||
console.log('📁 변환된 파일 배열:', fileArray)
|
||||
|
||||
const newImages = []
|
||||
let processedCount = 0
|
||||
|
||||
fileArray.forEach((file, index) => {
|
||||
// 이미지 파일인지 확인
|
||||
if (file && file.type && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
newImages.push(e.target.result)
|
||||
processedCount++
|
||||
|
||||
// 모든 파일 처리 완료 시
|
||||
if (processedCount === fileArray.length) {
|
||||
console.log('📁 모든 이미지 처리 완료:', newImages.length)
|
||||
// 기존 이미지에 새 이미지 추가
|
||||
formData.value.images.push(...newImages)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = (error) => {
|
||||
console.error('파일 읽기 오류:', error)
|
||||
processedCount++
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
} else {
|
||||
console.warn('지원하지 않는 파일 형식:', file?.type)
|
||||
processedCount++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 제거
|
||||
*/
|
||||
const removeImage = (index) => {
|
||||
formData.value.images.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 생성 요청
|
||||
*/
|
||||
const handleGenerate = () => {
|
||||
if (form.value?.validate()) {
|
||||
emit('generate', formData.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 저장 요청
|
||||
*/
|
||||
const handleSave = () => {
|
||||
emit('save')
|
||||
}
|
||||
|
||||
// 시작일이 변경되면 종료일 유효성 재검증
|
||||
watch(() => formData.value.promotionStartDate, () => {
|
||||
if (form.value) {
|
||||
form.value.validate()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
318
src/components/poster/PosterPreview.vue
Normal file
318
src/components/poster/PosterPreview.vue
Normal file
@ -0,0 +1,318 @@
|
||||
//* src/components/poster/PosterPreview.vue
|
||||
<template>
|
||||
<div class="poster-preview">
|
||||
<!-- 로딩 상태 -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="64"
|
||||
color="primary"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div class="text-h6">포스터 생성 중...</div>
|
||||
<div class="text-body-2 text-grey-600 mt-2">AI가 멋진 포스터를 만들고 있어요</div>
|
||||
</div>
|
||||
|
||||
<!-- 포스터 없음 -->
|
||||
<div v-else-if="!posterData" class="text-center py-12">
|
||||
<v-icon size="80" color="grey-lighten-2" class="mb-4">
|
||||
mdi-image-outline
|
||||
</v-icon>
|
||||
<div class="text-h6 mb-2">포스터 미리보기</div>
|
||||
<div class="text-body-2 text-grey-600">
|
||||
좌측 폼을 작성하고 'AI 포스터 생성' 버튼을 클릭하세요
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 생성된 포스터 -->
|
||||
<div v-else class="poster-result">
|
||||
<!-- 포스터 이미지 -->
|
||||
<div class="poster-image-container mb-4">
|
||||
<v-img
|
||||
:src="posterData.posterImage || '/images/placeholder-poster.jpg'"
|
||||
:alt="posterData.title"
|
||||
cover
|
||||
class="rounded-lg elevation-4"
|
||||
style="aspect-ratio: 3/4; max-height: 400px;"
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-progress-circular indeterminate />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:error>
|
||||
<div class="d-flex align-center justify-center fill-height bg-grey-lighten-3">
|
||||
<v-icon size="48" color="grey">mdi-image-broken</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
</v-img>
|
||||
|
||||
<!-- 이미지 액션 버튼 -->
|
||||
<div class="image-actions mt-3">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-download"
|
||||
@click="downloadPoster"
|
||||
class="mr-2"
|
||||
>
|
||||
다운로드
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-share-variant"
|
||||
@click="sharePoster"
|
||||
>
|
||||
공유
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 포스터 정보 -->
|
||||
<v-card variant="outlined" class="mb-4">
|
||||
<v-card-title class="text-h6">
|
||||
{{ posterData.title }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div v-if="posterData.content" class="mb-3">
|
||||
<div class="text-subtitle-2 mb-1">포스터 내용:</div>
|
||||
<div class="text-body-2">{{ posterData.content }}</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-chip
|
||||
:color="getStatusColor(posterData.status)"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ getStatusText(posterData.status) }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ posterData.contentType }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div v-if="posterData.imageStyle" class="text-caption text-grey-600">
|
||||
스타일: {{ posterData.imageStyle }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 포스터 사이즈 옵션 -->
|
||||
<v-card v-if="posterData.posterSizes && Object.keys(posterData.posterSizes).length > 0" variant="outlined">
|
||||
<v-card-title class="text-subtitle-1">
|
||||
<v-icon class="mr-2">mdi-resize</v-icon>
|
||||
다양한 사이즈
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-chip
|
||||
v-for="(url, size) in posterData.posterSizes"
|
||||
:key="size"
|
||||
@click="viewPosterSize(size, url)"
|
||||
class="cursor-pointer"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ size }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 원본 이미지들 -->
|
||||
<div v-if="posterData.originalImages && posterData.originalImages.length > 0" class="mt-4">
|
||||
<div class="text-subtitle-2 mb-2">사용된 원본 이미지:</div>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="(image, index) in posterData.originalImages"
|
||||
:key="index"
|
||||
cols="6"
|
||||
sm="4"
|
||||
>
|
||||
<v-img
|
||||
:src="image"
|
||||
:alt="`원본 이미지 ${index + 1}`"
|
||||
cover
|
||||
height="80"
|
||||
class="rounded"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 포스터 사이즈 보기 다이얼로그 -->
|
||||
<v-dialog v-model="showSizeDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
포스터 사이즈: {{ selectedSize }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="text-center">
|
||||
<v-img
|
||||
:src="selectedSizeUrl"
|
||||
:alt="`포스터 ${selectedSize}`"
|
||||
contain
|
||||
max-height="400"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
@click="downloadSelectedSize"
|
||||
>
|
||||
다운로드
|
||||
</v-btn>
|
||||
<v-btn @click="showSizeDialog = false">
|
||||
닫기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 포스터 미리보기 컴포넌트
|
||||
* - 생성된 포스터 표시
|
||||
* - 다운로드 및 공유 기능
|
||||
* - 다양한 사이즈 보기
|
||||
*/
|
||||
|
||||
// Props 정의
|
||||
const props = defineProps({
|
||||
posterData: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 반응형 데이터
|
||||
const showSizeDialog = ref(false)
|
||||
const selectedSize = ref('')
|
||||
const selectedSizeUrl = ref('')
|
||||
|
||||
/**
|
||||
* 상태 색상 반환
|
||||
*/
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'DRAFT': 'orange',
|
||||
'PUBLISHED': 'success',
|
||||
'ARCHIVED': 'grey'
|
||||
}
|
||||
return colors[status] || 'primary'
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 텍스트 반환
|
||||
*/
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'DRAFT': '임시저장',
|
||||
'PUBLISHED': '발행',
|
||||
'ARCHIVED': '보관'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 다운로드
|
||||
*/
|
||||
const downloadPoster = () => {
|
||||
if (!props.posterData?.posterImage) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = props.posterData.posterImage
|
||||
link.download = `${props.posterData.title || '포스터'}.jpg`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 공유
|
||||
*/
|
||||
const sharePoster = async () => {
|
||||
if (!props.posterData?.posterImage) return
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: props.posterData.title,
|
||||
text: '생성된 홍보 포스터를 확인해보세요!',
|
||||
url: props.posterData.posterImage
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('공유 취소됨')
|
||||
}
|
||||
} else {
|
||||
// 클립보드에 URL 복사
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.posterData.posterImage)
|
||||
// 성공 알림 표시 (부모 컴포넌트에서 처리)
|
||||
} catch (error) {
|
||||
console.error('클립보드 복사 실패:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 사이즈 포스터 보기
|
||||
*/
|
||||
const viewPosterSize = (size, url) => {
|
||||
selectedSize.value = size
|
||||
selectedSizeUrl.value = url
|
||||
showSizeDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 사이즈 포스터 다운로드
|
||||
*/
|
||||
const downloadSelectedSize = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = selectedSizeUrl.value
|
||||
link.download = `${props.posterData.title || '포스터'}_${selectedSize.value}.jpg`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.poster-preview {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.poster-image-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@ -1,4 +1,5 @@
|
||||
//* src/services/api.js - 수정된 API URL 설정
|
||||
//* src/services/api.js - 수정된 버전 (createImageApiInstance 함수 추가)
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
// 런타임 환경 설정에서 API URL 가져오기
|
||||
@ -6,15 +7,14 @@ const getApiUrls = () => {
|
||||
const config = window.__runtime_config__ || {}
|
||||
return {
|
||||
GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3',
|
||||
AUTH_URL: config.AUTH_URL || 'http://smarketing.20.249.184.228.nip.io/api/auth',
|
||||
MEMBER_URL: config.MEMBER_URL || 'http://smarketing.20.249.184.228.nip.io/api/member',
|
||||
STORE_URL: config.STORE_URL || 'http://smarketing.20.249.184.228.nip.io/api/store',
|
||||
CONTENT_URL: config.CONTENT_URL || 'http://smarketing.20.249.184.228.nip.io/api/content',
|
||||
MENU_URL: config.MENU_URL || 'http://smarketing.20.249.184.228.nip.io/api/menu',
|
||||
// ⚠️ 수정: 매출 API는 store 서비스 (포트 8082)
|
||||
SALES_URL: config.SALES_URL || 'http://smarketing.20.249.184.228.nip.io/api/sales',
|
||||
// ⚠️ 수정: 추천 API는 ai-recommend 서비스 (포트 8084)
|
||||
RECOMMEND_URL: config.RECOMMEND_URL || 'http://smarketing.20.249.184.228.nip.io/api/recommendations'
|
||||
AUTH_URL: config.AUTH_URL || 'http://localhost:8081/api/auth',
|
||||
MEMBER_URL: config.MEMBER_URL || 'http://localhost:8081/api/member',
|
||||
STORE_URL: config.STORE_URL || 'http://localhost:8082/api/store',
|
||||
CONTENT_URL: config.CONTENT_URL || 'http://localhost:8083/api/content',
|
||||
MENU_URL: config.MENU_URL || 'http://localhost:8082/api/menu',
|
||||
SALES_URL: config.SALES_URL || 'http://localhost:8082/api/sales',
|
||||
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendations',
|
||||
IMAGE_URL: config.IMAGE_URL || 'http://localhost:8082/api/images'
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +37,6 @@ const createApiInstance = (baseURL) => {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// ⚠️ 추가: 요청 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🌐 [API_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
|
||||
}
|
||||
@ -52,14 +51,12 @@ const createApiInstance = (baseURL) => {
|
||||
// 응답 인터셉터 - 토큰 갱신 및 에러 처리
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
// ⚠️ 추가: 응답 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ [API_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
// ⚠️ 추가: 에러 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`❌ [API_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
|
||||
}
|
||||
@ -102,10 +99,155 @@ const createApiInstance = (baseURL) => {
|
||||
return instance
|
||||
}
|
||||
|
||||
// ✅ 이미지 업로드 전용 API 인스턴스 생성 함수 추가
|
||||
const createImageApiInstance = (baseURL) => {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 60000, // 이미지 업로드는 시간이 더 걸릴 수 있음
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 요청 인터셉터 - JWT 토큰 자동 추가
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🌐 [IMG_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
|
||||
console.log('FormData 포함:', config.data instanceof FormData)
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 응답 인터셉터
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ [IMG_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`❌ [IMG_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
|
||||
}
|
||||
|
||||
// 토큰 갱신 로직은 기존과 동일
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (refreshToken) {
|
||||
const refreshResponse = await axios.post(`${getApiUrls().AUTH_URL}/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
const { accessToken, refreshToken: newRefreshToken } = refreshResponse.data.data
|
||||
localStorage.setItem('accessToken', accessToken)
|
||||
localStorage.setItem('refreshToken', newRefreshToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return instance(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
// ✅ 메뉴 이미지 업로드 전용 API 인스턴스 생성 함수 추가
|
||||
const createMenuImageApiInstance = (baseURL) => {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
timeout: 60000, // 이미지 업로드는 시간이 더 걸릴 수 있음
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 요청 인터셉터 - JWT 토큰 자동 추가
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`🌐 [MENU_IMG_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
|
||||
console.log('FormData 포함:', config.data instanceof FormData)
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 응답 인터셉터
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(`✅ [MENU_IMG_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error(`❌ [MENU_IMG_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
|
||||
}
|
||||
|
||||
// 토큰 갱신 로직은 기존과 동일
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (refreshToken) {
|
||||
const refreshResponse = await axios.post(`${getApiUrls().AUTH_URL}/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
const { accessToken, refreshToken: newRefreshToken } = refreshResponse.data.data
|
||||
localStorage.setItem('accessToken', accessToken)
|
||||
localStorage.setItem('refreshToken', newRefreshToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return instance(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('userInfo')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
// API 인스턴스들 생성
|
||||
const apiUrls = getApiUrls()
|
||||
|
||||
// ⚠️ 추가: API URL 확인 로깅 (개발 환경에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔧 [API_CONFIG] API URLs 설정:', apiUrls)
|
||||
}
|
||||
@ -115,8 +257,11 @@ export const authApi = createApiInstance(apiUrls.AUTH_URL)
|
||||
export const storeApi = createApiInstance(apiUrls.STORE_URL)
|
||||
export const contentApi = createApiInstance(apiUrls.CONTENT_URL)
|
||||
export const menuApi = createApiInstance(apiUrls.MENU_URL)
|
||||
export const menuImageApi = createMenuImageApiInstance(apiUrls.MENU_URL) // ✅ 추가
|
||||
export const salesApi = createApiInstance(apiUrls.SALES_URL)
|
||||
export const recommendApi = createApiInstance(apiUrls.RECOMMEND_URL)
|
||||
export const imageApi = createApiInstance(apiUrls.IMAGE_URL)
|
||||
export const apiWithImage = imageApi // 별칭 (기존 코드 호환성)
|
||||
|
||||
// 기본 API 인스턴스 (Gateway URL 사용)
|
||||
export const api = createApiInstance(apiUrls.GATEWAY_URL)
|
||||
@ -185,7 +330,7 @@ export const formatSuccessResponse = (data, message = '요청이 성공적으로
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 추가: API 상태 확인 함수
|
||||
// API 상태 확인 함수
|
||||
export const checkApiHealth = async () => {
|
||||
const results = {}
|
||||
|
||||
@ -214,11 +359,14 @@ export const checkApiHealth = async () => {
|
||||
return results
|
||||
}
|
||||
|
||||
// ⚠️ 추가: 개발 환경에서 전역 노출
|
||||
// 개발 환경에서 전역 노출
|
||||
if (import.meta.env.DEV) {
|
||||
window.__api_debug__ = {
|
||||
urls: apiUrls,
|
||||
instances: { memberApi, authApi, storeApi, contentApi, menuApi, salesApi, recommendApi },
|
||||
instances: {
|
||||
memberApi, authApi, storeApi, contentApi, menuApi, menuImageApi,
|
||||
salesApi, recommendApi, imageApi
|
||||
},
|
||||
checkHealth: checkApiHealth
|
||||
}
|
||||
console.log('🔧 [DEBUG] API 인스턴스가 window.__api_debug__에 노출됨')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -140,8 +140,10 @@ class StoreService {
|
||||
businessType: storeData.businessType,
|
||||
address: storeData.address,
|
||||
phoneNumber: storeData.phoneNumber,
|
||||
businessHours: storeData.businessHours,
|
||||
closedDays: storeData.closedDays,
|
||||
// ✅ 수정: businessHours 필드 처리
|
||||
businessHours: storeData.businessHours || `${storeData.openTime || '09:00'}-${storeData.closeTime || '21:00'}`,
|
||||
// ✅ 수정: closedDays 필드 처리
|
||||
closedDays: storeData.closedDays || storeData.holidays || '',
|
||||
seatCount: parseInt(storeData.seatCount) || 0,
|
||||
instaAccounts: storeData.instaAccounts || '',
|
||||
blogAccounts: storeData.blogAccounts || '',
|
||||
@ -150,9 +152,9 @@ class StoreService {
|
||||
|
||||
console.log('백엔드 전송 데이터:', requestData)
|
||||
|
||||
// PUT 요청 (storeId는 JWT에서 추출하므로 URL에 포함하지 않음)
|
||||
const response = await storeApi.put('/', requestData)
|
||||
|
||||
// ✅ 핵심 수정: 슬래시 제거하고 빈 문자열 사용
|
||||
console.log('API 호출 경로: PUT /api/store (baseURL + 빈 문자열)')
|
||||
const response = await storeApi.put('', requestData)
|
||||
console.log('매장 정보 수정 API 응답:', response.data)
|
||||
|
||||
if (response.data && (response.data.status === 200 || response.data.success !== false)) {
|
||||
|
||||
1162
src/store/content.js
1162
src/store/content.js
File diff suppressed because it is too large
Load Diff
@ -79,8 +79,10 @@ export const useStoreStore = defineStore('store', {
|
||||
}
|
||||
},
|
||||
|
||||
// src/store/index.js에서 fetchMenus 부분만 수정
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ✅ ID 필드 보장
|
||||
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ✅ 이미지 필드 매핑 수정
|
||||
*/
|
||||
async fetchMenus() {
|
||||
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
|
||||
@ -106,7 +108,7 @@ export const useStoreStore = defineStore('store', {
|
||||
console.log('Result.message:', result.message)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// ✅ 메뉴 데이터 ID 필드 보장 처리
|
||||
// ✅ 백엔드 MenuResponse의 필드명에 맞게 매핑 수정
|
||||
const menusWithId = (result.data || []).map(menu => {
|
||||
// ID 필드가 확실히 있도록 보장
|
||||
const menuId = menu.menuId || menu.id
|
||||
@ -115,6 +117,8 @@ export const useStoreStore = defineStore('store', {
|
||||
console.warn('⚠️ 메뉴 ID가 없는 항목 발견:', menu)
|
||||
}
|
||||
|
||||
console.log('메뉴 원본 데이터:', menu) // 디버깅용
|
||||
|
||||
return {
|
||||
...menu,
|
||||
id: menuId, // ✅ id 필드 확실히 설정
|
||||
@ -126,12 +130,16 @@ export const useStoreStore = defineStore('store', {
|
||||
description: menu.description || '',
|
||||
available: menu.available !== undefined ? menu.available : true,
|
||||
recommended: menu.recommended !== undefined ? menu.recommended : false,
|
||||
imageUrl: menu.imageUrl || '/images/menu-placeholder.png'
|
||||
// ✅ 이미지 필드 수정: 백엔드는 'image' 필드 사용
|
||||
imageUrl: menu.image || menu.imageUrl || '/images/menu-placeholder.png',
|
||||
image: menu.image || menu.imageUrl, // 백엔드 호환성
|
||||
createdAt: menu.createdAt,
|
||||
updatedAt: menu.updatedAt
|
||||
}
|
||||
})
|
||||
|
||||
// 메뉴 목록이 있는 경우
|
||||
console.log('✅ 메뉴 목록 설정 (ID 보장됨):', menusWithId)
|
||||
console.log('✅ 메뉴 목록 설정 (이미지 필드 매핑 완료):', menusWithId)
|
||||
this.menus = menusWithId
|
||||
return { success: true, data: menusWithId }
|
||||
} else {
|
||||
|
||||
281
src/store/poster.js
Normal file
281
src/store/poster.js
Normal file
@ -0,0 +1,281 @@
|
||||
//* src/store/poster.js - 이미지 처리 강화 및 디버깅 추가
|
||||
import { defineStore } from 'pinia'
|
||||
import { contentService } from '@/services/content'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export const usePosterStore = defineStore('poster', {
|
||||
state: () => ({
|
||||
posters: [],
|
||||
currentPoster: null,
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getPosterById: (state) => (id) => {
|
||||
return state.posters.find(poster => poster.id === id)
|
||||
},
|
||||
|
||||
recentPosters: (state) => {
|
||||
return state.posters
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
.slice(0, 10)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 포스터 생성 - 이미지 처리 강화
|
||||
*/
|
||||
async generatePoster(posterData) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
console.log('🎯 [POSTER_STORE] 포스터 생성 요청 받음:', posterData)
|
||||
console.log('📁 [POSTER_STORE] 이미지 상태 확인:', {
|
||||
hasImages: !!posterData.images,
|
||||
isArray: Array.isArray(posterData.images),
|
||||
imageCount: posterData.images?.length || 0,
|
||||
imageDetails: posterData.images?.map((img, idx) => ({
|
||||
index: idx,
|
||||
type: typeof img,
|
||||
length: img?.length,
|
||||
isBase64: typeof img === 'string' && img.startsWith('data:image/'),
|
||||
preview: typeof img === 'string' ? img.substring(0, 30) + '...' : 'not string'
|
||||
})) || []
|
||||
})
|
||||
|
||||
// ✅ 이미지 전처리 및 검증
|
||||
let processedImages = []
|
||||
if (posterData.images && Array.isArray(posterData.images) && posterData.images.length > 0) {
|
||||
console.log('📁 [POSTER_STORE] 이미지 전처리 시작...')
|
||||
|
||||
processedImages = posterData.images
|
||||
.filter((img, index) => {
|
||||
const isValid = img &&
|
||||
typeof img === 'string' &&
|
||||
img.length > 100 &&
|
||||
(img.startsWith('data:image/') || img.startsWith('http'))
|
||||
|
||||
console.log(`📁 [POSTER_STORE] 이미지 ${index + 1} 검증:`, {
|
||||
isValid,
|
||||
type: typeof img,
|
||||
length: img?.length,
|
||||
format: img?.substring(0, 20) || 'unknown'
|
||||
})
|
||||
|
||||
return isValid
|
||||
})
|
||||
|
||||
console.log('📁 [POSTER_STORE] 전처리 결과:', {
|
||||
원본: posterData.images.length,
|
||||
유효: processedImages.length,
|
||||
제거됨: posterData.images.length - processedImages.length
|
||||
})
|
||||
|
||||
if (processedImages.length === 0) {
|
||||
throw new Error('유효한 이미지가 없습니다. 이미지를 다시 업로드해 주세요.')
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ [POSTER_STORE] 이미지가 없습니다!')
|
||||
throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.')
|
||||
}
|
||||
|
||||
// ✅ API 요청에 맞는 형태로 데이터 변환 - 검증된 이미지 사용
|
||||
const requestData = {
|
||||
storeId: posterData.storeId,
|
||||
title: posterData.title,
|
||||
targetAudience: posterData.targetAudience,
|
||||
promotionStartDate: posterData.promotionStartDate,
|
||||
promotionEndDate: posterData.promotionEndDate,
|
||||
images: processedImages, // 검증된 이미지만 사용
|
||||
targetAge: posterData.targetAge
|
||||
}
|
||||
|
||||
// 선택적 필드들
|
||||
if (posterData.eventName) requestData.eventName = posterData.eventName
|
||||
if (posterData.imageStyle) requestData.imageStyle = posterData.imageStyle
|
||||
if (posterData.promotionType) requestData.promotionType = posterData.promotionType
|
||||
if (posterData.emotionIntensity) requestData.emotionIntensity = posterData.emotionIntensity
|
||||
if (posterData.category) requestData.category = posterData.category
|
||||
if (posterData.requirement) requestData.requirement = posterData.requirement
|
||||
if (posterData.toneAndManner) requestData.toneAndManner = posterData.toneAndManner
|
||||
if (posterData.startDate) requestData.startDate = posterData.startDate
|
||||
if (posterData.endDate) requestData.endDate = posterData.endDate
|
||||
if (posterData.photoStyle) requestData.photoStyle = posterData.photoStyle
|
||||
|
||||
console.log('📤 [POSTER_STORE] 최종 요청 데이터:', {
|
||||
...requestData,
|
||||
images: `${requestData.images.length}개 이미지 (${Math.round(JSON.stringify(requestData.images).length / 1024)}KB)`
|
||||
})
|
||||
|
||||
// ✅ 마지막 검증
|
||||
if (!requestData.title) {
|
||||
throw new Error('제목은 필수입니다.')
|
||||
}
|
||||
if (!requestData.targetAudience) {
|
||||
throw new Error('홍보 대상은 필수입니다.')
|
||||
}
|
||||
if (!requestData.images || requestData.images.length === 0) {
|
||||
throw new Error('이미지는 필수입니다.')
|
||||
}
|
||||
|
||||
console.log('🚀 [POSTER_STORE] contentService.generatePoster 호출...')
|
||||
const result = await contentService.generatePoster(requestData)
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ [POSTER_STORE] 포스터 생성 성공:', result.data)
|
||||
this.currentPoster = result.data
|
||||
return result
|
||||
} else {
|
||||
console.error('❌ [POSTER_STORE] 포스터 생성 실패:', result.message)
|
||||
this.error = result.message
|
||||
return result
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [POSTER_STORE] 포스터 생성 예외:', error)
|
||||
|
||||
// 상세한 오류 정보 로깅
|
||||
if (error.response) {
|
||||
console.error('❌ [POSTER_STORE] HTTP 오류:', {
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
data: error.response.data
|
||||
})
|
||||
}
|
||||
|
||||
this.error = error.message || '포스터 생성 중 오류가 발생했습니다.'
|
||||
return {
|
||||
success: false,
|
||||
message: this.error
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 포스터 저장
|
||||
*/
|
||||
async savePoster(saveData) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
console.log('💾 [POSTER_STORE] 포스터 저장 요청:', saveData)
|
||||
|
||||
const result = await contentService.savePoster(saveData)
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ [POSTER_STORE] 포스터 저장 성공')
|
||||
// 저장된 포스터를 목록에 추가
|
||||
if (result.data) {
|
||||
this.posters.unshift(result.data)
|
||||
}
|
||||
return result
|
||||
} else {
|
||||
console.error('❌ [POSTER_STORE] 포스터 저장 실패:', result.message)
|
||||
this.error = result.message
|
||||
return result
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [POSTER_STORE] 포스터 저장 예외:', error)
|
||||
this.error = error.message || '포스터 저장 중 오류가 발생했습니다.'
|
||||
return {
|
||||
success: false,
|
||||
message: this.error
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 포스터 목록 조회
|
||||
*/
|
||||
async fetchPosters() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const result = await contentService.getContents({
|
||||
contentType: 'poster',
|
||||
sortBy: 'latest'
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
this.posters = result.data || []
|
||||
return result
|
||||
} else {
|
||||
this.error = result.message
|
||||
return result
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [POSTER_STORE] 포스터 목록 조회 예외:', error)
|
||||
this.error = error.message || '포스터 목록 조회 중 오류가 발생했습니다.'
|
||||
return {
|
||||
success: false,
|
||||
message: this.error
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 포스터 삭제
|
||||
*/
|
||||
async deletePoster(posterId) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const result = await contentService.deleteContent(posterId)
|
||||
|
||||
if (result.success) {
|
||||
// 목록에서 삭제
|
||||
this.posters = this.posters.filter(poster => poster.id !== posterId)
|
||||
|
||||
// 현재 포스터가 삭제된 포스터라면 초기화
|
||||
if (this.currentPoster?.id === posterId) {
|
||||
this.currentPoster = null
|
||||
}
|
||||
|
||||
return result
|
||||
} else {
|
||||
this.error = result.message
|
||||
return result
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [POSTER_STORE] 포스터 삭제 예외:', error)
|
||||
this.error = error.message || '포스터 삭제 중 오류가 발생했습니다.'
|
||||
return {
|
||||
success: false,
|
||||
message: this.error
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 에러 상태 초기화
|
||||
*/
|
||||
clearError() {
|
||||
this.error = null
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 포스터 설정
|
||||
*/
|
||||
setCurrentPoster(poster) {
|
||||
this.currentPoster = poster
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -1,533 +1,243 @@
|
||||
//* src/services/store.js - 병합 충돌 해결된 매장 서비스
|
||||
import { storeApi, menuApi, handleApiError, formatSuccessResponse } from './api.js'
|
||||
//* src/store/content.js 수정 - 매장 정보 조회 후 콘텐츠 목록 조회
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import contentService from '@/services/content'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export const useContentStore = defineStore('content', () => {
|
||||
// 상태
|
||||
const contentList = ref([])
|
||||
const contents = ref([]) // ContentManagementView에서 사용하는 속성
|
||||
const ongoingContents = ref([])
|
||||
const selectedContent = ref(null)
|
||||
const generatedContent = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// computed 속성들
|
||||
const contentCount = computed(() => contentList.value.length)
|
||||
const ongoingContentCount = computed(() => ongoingContents.value.length)
|
||||
|
||||
// 콘텐츠 목록 로딩 (ContentManagementView에서 사용)
|
||||
const loadContents = async (filters = {}) => {
|
||||
console.log('=== 콘텐츠 목록 조회 시작 ===')
|
||||
isLoading.value = true
|
||||
|
||||
/**
|
||||
* 매장 관련 API 서비스
|
||||
* 백엔드 Store Controller와 연동 (포트 8082)
|
||||
*/
|
||||
class StoreService {
|
||||
/**
|
||||
* 매장 등록 (STR-015: 매장 등록)
|
||||
* @param {Object} storeData - 매장 정보
|
||||
* @returns {Promise<Object>} 매장 등록 결과
|
||||
*/
|
||||
async registerStore(storeData) {
|
||||
try {
|
||||
console.log('=== 매장 등록 API 호출 ===')
|
||||
console.log('요청 데이터:', storeData)
|
||||
// 1단계: 매장 정보 조회하여 실제 storeId 가져오기
|
||||
const userInfo = useAuthStore().user
|
||||
let storeId = null
|
||||
|
||||
// 백엔드 StoreCreateRequest에 맞는 형태로 변환
|
||||
const requestData = {
|
||||
storeName: storeData.storeName,
|
||||
businessType: storeData.businessType,
|
||||
address: storeData.address,
|
||||
phoneNumber: storeData.phoneNumber,
|
||||
businessHours: storeData.businessHours,
|
||||
closedDays: storeData.closedDays,
|
||||
seatCount: parseInt(storeData.seatCount) || 0,
|
||||
instaAccounts: storeData.instaAccounts || '',
|
||||
blogAccounts: storeData.blogAccounts || '',
|
||||
description: storeData.description || ''
|
||||
try {
|
||||
// 매장 정보 API 호출
|
||||
const storeApiUrl = (window.__runtime_config__ && window.__runtime_config__.STORE_URL)
|
||||
? window.__runtime_config__.STORE_URL
|
||||
: 'http://localhost:8082/api/store'
|
||||
|
||||
console.log('매장 API URL:', storeApiUrl)
|
||||
|
||||
const token = localStorage.getItem('accessToken') || localStorage.getItem('auth_token') || localStorage.getItem('token')
|
||||
const storeResponse = await fetch(`${storeApiUrl}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('=== 각 필드 상세 검증 ===')
|
||||
console.log('storeName:', requestData.storeName, '(타입:', typeof requestData.storeName, ')')
|
||||
console.log('businessType:', requestData.businessType, '(타입:', typeof requestData.businessType, ')')
|
||||
console.log('address:', requestData.address, '(타입:', typeof requestData.address, ')')
|
||||
console.log('seatCount:', requestData.seatCount, '(타입:', typeof requestData.seatCount, ')')
|
||||
|
||||
console.log('백엔드 전송 데이터:', requestData)
|
||||
|
||||
const response = await storeApi.post('/register', requestData)
|
||||
|
||||
console.log('매장 등록 API 응답:', response.data)
|
||||
|
||||
// 백엔드 응답 구조에 맞게 처리
|
||||
if (response.data && (response.data.status === 200 || response.data.success !== false)) {
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '매장이 등록되었습니다.',
|
||||
data: response.data.data
|
||||
}
|
||||
if (storeResponse.ok) {
|
||||
const storeData = await storeResponse.json()
|
||||
storeId = storeData.data?.storeId
|
||||
console.log('✅ 매장 정보 조회 성공, storeId:', storeId)
|
||||
} else {
|
||||
throw new Error(response.data.message || '매장 등록에 실패했습니다.')
|
||||
throw new Error(`매장 정보 조회 실패: ${storeResponse.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 등록 실패:', error)
|
||||
|
||||
if (error.response) {
|
||||
console.error('응답 상태:', error.response.status)
|
||||
console.error('응답 데이터:', error.response.data)
|
||||
console.error('❌ 매장 정보 조회 실패:', error)
|
||||
throw new Error('매장 정보를 조회할 수 없습니다.')
|
||||
}
|
||||
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 정보 조회 (STR-005: 매장 정보 관리)
|
||||
* @returns {Promise<Object>} 매장 정보
|
||||
*/
|
||||
async getStore() {
|
||||
try {
|
||||
console.log('=== 매장 정보 조회 API 호출 ===')
|
||||
|
||||
// URL 슬래시 문제 해결: 빈 문자열로 호출하여 '/api/store'가 되도록 함
|
||||
const response = await storeApi.get('')
|
||||
|
||||
console.log('매장 정보 조회 API 응답:', response.data)
|
||||
|
||||
// 백엔드 응답 구조 수정: 디버깅 결과에 맞게 처리
|
||||
if (response.data && response.data.status === 200 && response.data.data) {
|
||||
console.log('✅ 매장 정보 조회 성공:', response.data.data)
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '매장 정보를 조회했습니다.',
|
||||
data: response.data.data
|
||||
}
|
||||
} else if (response.data && response.data.status === 404) {
|
||||
// 매장이 없는 경우
|
||||
console.log('⚠️ 등록된 매장이 없음')
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
data: null
|
||||
}
|
||||
} else {
|
||||
console.warn('예상치 못한 응답 구조:', response.data)
|
||||
throw new Error(response.data.message || '매장 정보를 찾을 수 없습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 정보 조회 실패:', error)
|
||||
|
||||
// 404 오류 처리 (매장이 없음)
|
||||
if (error.response?.status === 404) {
|
||||
return {
|
||||
success: false,
|
||||
message: '등록된 매장이 없습니다',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
// 500 오류 처리 (서버 내부 오류)
|
||||
if (error.response?.status === 500) {
|
||||
console.error('서버 내부 오류 - 백엔드 로그 확인 필요:', error.response?.data)
|
||||
return {
|
||||
success: false,
|
||||
message: '서버 오류가 발생했습니다. 관리자에게 문의하세요.',
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매장 정보 수정 (STR-010: 매장 수정)
|
||||
* @param {number} storeId - 매장 ID (현재는 사용하지 않음 - JWT에서 사용자 확인)
|
||||
* @param {Object} storeData - 수정할 매장 정보
|
||||
* @returns {Promise<Object>} 매장 수정 결과
|
||||
*/
|
||||
async updateStore(storeId, storeData) {
|
||||
try {
|
||||
console.log('=== 매장 정보 수정 API 호출 ===')
|
||||
console.log('요청 데이터:', storeData)
|
||||
|
||||
// 백엔드 StoreUpdateRequest에 맞는 형태로 변환
|
||||
const requestData = {
|
||||
storeName: storeData.storeName,
|
||||
businessType: storeData.businessType,
|
||||
address: storeData.address,
|
||||
phoneNumber: storeData.phoneNumber,
|
||||
businessHours: storeData.businessHours,
|
||||
closedDays: storeData.closedDays,
|
||||
seatCount: parseInt(storeData.seatCount) || 0,
|
||||
instaAccounts: storeData.instaAccounts || '',
|
||||
blogAccounts: storeData.blogAccounts || '',
|
||||
description: storeData.description || ''
|
||||
}
|
||||
|
||||
console.log('백엔드 전송 데이터:', requestData)
|
||||
|
||||
// PUT 요청 (storeId는 JWT에서 추출하므로 URL에 포함하지 않음)
|
||||
const response = await storeApi.put('/', requestData)
|
||||
|
||||
console.log('매장 정보 수정 API 응답:', response.data)
|
||||
|
||||
if (response.data && (response.data.status === 200 || response.data.success !== false)) {
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '매장 정보가 수정되었습니다.',
|
||||
data: response.data.data
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '매장 정보 수정에 실패했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 정보 수정 실패:', error)
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출 정보 조회 (STR-020: 대시보드)
|
||||
* @param {string} period - 조회 기간 (today, week, month, year)
|
||||
* @returns {Promise<Object>} 매출 정보
|
||||
*/
|
||||
async getSales(period = 'today') {
|
||||
try {
|
||||
console.log('=== 매출 정보 조회 API 호출 ===')
|
||||
console.log('조회 기간:', period)
|
||||
|
||||
// 현재는 목업 데이터 반환 (추후 실제 API 연동 시 수정)
|
||||
const mockSalesData = {
|
||||
todaySales: 150000,
|
||||
yesterdaySales: 120000,
|
||||
changeRate: 25.0,
|
||||
monthlyTarget: 3000000,
|
||||
achievementRate: 45.2,
|
||||
yearSales: this.generateMockYearSales()
|
||||
}
|
||||
|
||||
// 매출 트렌드 분석 추가
|
||||
if (mockSalesData.yearSales && mockSalesData.yearSales.length > 0) {
|
||||
mockSalesData.trendAnalysis = this.analyzeSalesTrend(mockSalesData.yearSales)
|
||||
mockSalesData.chartData = this.prepareChartData(mockSalesData.yearSales)
|
||||
}
|
||||
|
||||
return formatSuccessResponse(mockSalesData, '매출 정보를 조회했습니다.')
|
||||
} catch (error) {
|
||||
console.error('매출 정보 조회 실패:', error)
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 (최종 통합 버전 - 모든 충돌 해결)
|
||||
* @param {number} storeId - 매장 ID (옵션, 없으면 목업 데이터 반환)
|
||||
* @returns {Promise<Object>} 메뉴 목록
|
||||
*/
|
||||
async getMenus(storeId) {
|
||||
try {
|
||||
console.log('=== 메뉴 목록 조회 API 호출 ===')
|
||||
console.log('매장 ID:', storeId)
|
||||
|
||||
// storeId가 없으면 목업 데이터 반환 (개발 중)
|
||||
if (!storeId) {
|
||||
console.warn('매장 ID가 없어서 목업 데이터 반환')
|
||||
const mockMenus = [
|
||||
{
|
||||
menuId: 1, // id 대신 menuId 사용
|
||||
id: 1, // 호환성을 위해
|
||||
name: '아메리카노',
|
||||
menuName: '아메리카노', // 백엔드 형식
|
||||
price: 4000,
|
||||
category: '커피',
|
||||
description: '진한 풍미의 아메리카노',
|
||||
imageUrl: '/images/americano.jpg',
|
||||
isAvailable: true,
|
||||
available: true // 백엔드 형식
|
||||
},
|
||||
{
|
||||
menuId: 2,
|
||||
id: 2,
|
||||
name: '카페라떼',
|
||||
menuName: '카페라떼',
|
||||
price: 4500,
|
||||
category: '커피',
|
||||
description: '부드러운 우유가 들어간 라떼',
|
||||
imageUrl: '/images/latte.jpg',
|
||||
isAvailable: true,
|
||||
available: true
|
||||
}
|
||||
]
|
||||
return formatSuccessResponse(mockMenus, '목업 메뉴 목록을 조회했습니다.')
|
||||
throw new Error('매장 ID를 찾을 수 없습니다.')
|
||||
}
|
||||
|
||||
// 실제 백엔드 API 호출 시도
|
||||
try {
|
||||
// GET /api/menu?storeId={storeId}
|
||||
const response = await menuApi.get('', {
|
||||
params: { storeId }
|
||||
})
|
||||
console.log('사용자 정보:', userInfo)
|
||||
console.log('조회된 storeId:', storeId)
|
||||
|
||||
console.log('메뉴 목록 조회 API 응답:', response.data)
|
||||
// 2단계: 조회된 storeId로 콘텐츠 목록 조회
|
||||
const apiFilters = {
|
||||
platform: filters.platform || null, // 전체, INSTAGRAM, NAVER_BLOG, POSTER 등
|
||||
storeId: storeId,
|
||||
sortBy: filters.sortBy || 'latest'
|
||||
// contentType, period는 백엔드에서 사용하지 않으므로 제외
|
||||
}
|
||||
|
||||
if (response.data && response.data.status === 200) {
|
||||
// 백엔드에서 받은 메뉴 데이터를 프론트엔드 형식으로 변환
|
||||
const menus = response.data.data.map(menu => ({
|
||||
menuId: menu.menuId,
|
||||
id: menu.menuId, // 호환성을 위해
|
||||
storeId: menu.storeId,
|
||||
menuName: menu.menuName,
|
||||
name: menu.menuName, // 호환성을 위해
|
||||
category: menu.category,
|
||||
price: menu.price,
|
||||
description: menu.description,
|
||||
available: menu.available !== undefined ? menu.available : true,
|
||||
isAvailable: menu.available !== undefined ? menu.available : true, // 호환성
|
||||
imageUrl: menu.imageUrl || '/images/menu-placeholder.png',
|
||||
createdAt: menu.createdAt,
|
||||
updatedAt: menu.updatedAt
|
||||
}))
|
||||
console.log('API 요청 필터:', apiFilters)
|
||||
|
||||
return formatSuccessResponse(menus, '메뉴 목록을 조회했습니다.')
|
||||
const result = await contentService.getContents(apiFilters)
|
||||
|
||||
console.log('🔍 contentService.getContents 결과:', result)
|
||||
console.log('🔍 result.success:', result.success)
|
||||
console.log('🔍 result.data:', result.data)
|
||||
console.log('🔍 result.data 타입:', typeof result.data)
|
||||
console.log('🔍 result.data 길이:', result.data?.length)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
contents.value = result.data || []
|
||||
contentList.value = result.data || []
|
||||
console.log('✅ 콘텐츠 로딩 성공:', contents.value.length, '개')
|
||||
return { success: true }
|
||||
} else {
|
||||
throw new Error(response.data.message || '메뉴 목록을 찾을 수 없습니다.')
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error('백엔드 API 호출 실패:', apiError)
|
||||
|
||||
// 백엔드 미구현이나 네트워크 오류 시 목업 데이터 반환
|
||||
if (apiError.response?.status === 404 ||
|
||||
apiError.code === 'ECONNREFUSED' ||
|
||||
apiError.message.includes('Network Error')) {
|
||||
console.warn('백엔드 미구현 - 목업 데이터 반환')
|
||||
|
||||
const mockMenus = [
|
||||
{
|
||||
menuId: 1,
|
||||
id: 1,
|
||||
storeId: storeId,
|
||||
name: '아메리카노',
|
||||
menuName: '아메리카노',
|
||||
price: 4000,
|
||||
category: '커피',
|
||||
description: '진한 풍미의 아메리카노',
|
||||
imageUrl: '/images/americano.jpg',
|
||||
isAvailable: true,
|
||||
available: true
|
||||
},
|
||||
{
|
||||
menuId: 2,
|
||||
id: 2,
|
||||
storeId: storeId,
|
||||
name: '카페라떼',
|
||||
menuName: '카페라떼',
|
||||
price: 4500,
|
||||
category: '커피',
|
||||
description: '부드러운 우유가 들어간 라떼',
|
||||
imageUrl: '/images/latte.jpg',
|
||||
isAvailable: true,
|
||||
available: true
|
||||
}
|
||||
]
|
||||
|
||||
return formatSuccessResponse(mockMenus, '목업 메뉴 목록을 조회했습니다. (백엔드 미구현)')
|
||||
}
|
||||
|
||||
throw apiError
|
||||
console.error('❌ 콘텐츠 로딩 실패:', result.error)
|
||||
contents.value = []
|
||||
contentList.value = []
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메뉴 목록 조회 실패:', error)
|
||||
return handleApiError(error)
|
||||
console.error('❌ 콘텐츠 로딩 실패:', error)
|
||||
contents.value = []
|
||||
contentList.value = []
|
||||
return { success: false, error: error.message || '네트워크 오류가 발생했습니다.' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 별칭 (fetchMenus)
|
||||
* @param {number} storeId - 매장 ID
|
||||
* @returns {Promise<Object>} 메뉴 목록
|
||||
*/
|
||||
async fetchMenus(storeId) {
|
||||
return await this.getMenus(storeId)
|
||||
// AI 콘텐츠 생성
|
||||
const generateContent = async (type, formData) => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
let result
|
||||
if (type === 'sns') {
|
||||
result = await contentService.generateSnsContent(formData)
|
||||
} else if (type === 'poster') {
|
||||
result = await contentService.generatePoster(formData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 목업 메뉴 데이터 생성
|
||||
* @param {number} storeId - 매장 ID (옵션)
|
||||
* @returns {Array} 목업 메뉴 배열
|
||||
*/
|
||||
getMockMenus(storeId = null) {
|
||||
return [
|
||||
{
|
||||
menuId: 1,
|
||||
id: 1,
|
||||
storeId: storeId,
|
||||
name: '아메리카노',
|
||||
menuName: '아메리카노',
|
||||
price: 4000,
|
||||
category: '커피',
|
||||
description: '진한 풍미의 아메리카노',
|
||||
imageUrl: '/images/americano.jpg',
|
||||
isAvailable: true,
|
||||
available: true
|
||||
},
|
||||
{
|
||||
menuId: 2,
|
||||
id: 2,
|
||||
storeId: storeId,
|
||||
name: '카페라떼',
|
||||
menuName: '카페라떼',
|
||||
price: 4500,
|
||||
category: '커피',
|
||||
description: '부드러운 우유가 들어간 라떼',
|
||||
imageUrl: '/images/latte.jpg',
|
||||
isAvailable: true,
|
||||
available: true
|
||||
},
|
||||
{
|
||||
menuId: 3,
|
||||
id: 3,
|
||||
storeId: storeId,
|
||||
name: '에스프레소',
|
||||
menuName: '에스프레소',
|
||||
price: 3500,
|
||||
category: '커피',
|
||||
description: '진한 에스프레소 한 잔',
|
||||
imageUrl: '/images/espresso.jpg',
|
||||
isAvailable: true,
|
||||
available: true
|
||||
if (result.success) {
|
||||
generatedContent.value = result.data
|
||||
return { success: true, data: result.data }
|
||||
} else {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 목업 연간 매출 데이터 생성
|
||||
* @returns {Array} 목업 매출 데이터
|
||||
*/
|
||||
generateMockYearSales() {
|
||||
const salesData = []
|
||||
const today = new Date()
|
||||
|
||||
// 최근 90일 데이터 생성
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const date = new Date(today)
|
||||
date.setDate(date.getDate() - i)
|
||||
|
||||
// 랜덤하지만 현실적인 매출 패턴 생성
|
||||
const baseAmount = 120000 + Math.random() * 80000 // 120,000 ~ 200,000
|
||||
const weekendBonus = date.getDay() === 0 || date.getDay() === 6 ? 1.3 : 1.0
|
||||
const monthlyTrend = 1 + (Math.sin(i / 30) * 0.2) // 월별 트렌드
|
||||
|
||||
salesData.push({
|
||||
salesDate: date.toISOString().split('T')[0],
|
||||
salesAmount: Math.round(baseAmount * weekendBonus * monthlyTrend)
|
||||
})
|
||||
}
|
||||
|
||||
return salesData
|
||||
}
|
||||
|
||||
/**
|
||||
* 매출 트렌드 분석 및 변곡점 계산
|
||||
* @param {Array} yearSales - 연간 매출 데이터
|
||||
* @returns {Object} 트렌드 분석 결과
|
||||
*/
|
||||
analyzeSalesTrend(yearSales) {
|
||||
if (!yearSales || yearSales.length < 7) {
|
||||
return {
|
||||
inflectionPoints: [],
|
||||
overallTrend: 'insufficient_data',
|
||||
growthRate: 0
|
||||
} catch (error) {
|
||||
return { success: false, error: '네트워크 오류가 발생했습니다.' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜순 정렬
|
||||
const sortedData = [...yearSales].sort((a, b) =>
|
||||
new Date(a.salesDate) - new Date(b.salesDate)
|
||||
)
|
||||
// 콘텐츠 저장
|
||||
const saveContent = async (type, contentData) => {
|
||||
isLoading.value = true
|
||||
|
||||
const inflectionPoints = []
|
||||
const dailyData = sortedData.map(item => ({
|
||||
date: item.salesDate,
|
||||
amount: parseFloat(item.salesAmount) || 0
|
||||
}))
|
||||
try {
|
||||
let result
|
||||
if (type === 'sns') {
|
||||
result = await contentService.saveSnsContent(contentData)
|
||||
} else if (type === 'poster') {
|
||||
result = await contentService.savePoster(contentData)
|
||||
}
|
||||
|
||||
// 7일 이동평균으로 변곡점 탐지
|
||||
for (let i = 7; i < dailyData.length - 7; i++) {
|
||||
const prevWeekAvg = this.calculateMovingAverage(dailyData, i - 7, 7)
|
||||
const currentWeekAvg = this.calculateMovingAverage(dailyData, i, 7)
|
||||
const nextWeekAvg = this.calculateMovingAverage(dailyData, i + 7, 7)
|
||||
|
||||
const trend1 = currentWeekAvg - prevWeekAvg
|
||||
const trend2 = nextWeekAvg - currentWeekAvg
|
||||
|
||||
// 변곡점 조건: 트렌드 방향 변화 + 임계값 초과
|
||||
if (Math.sign(trend1) !== Math.sign(trend2) &&
|
||||
Math.abs(trend1) > 10000 && Math.abs(trend2) > 10000) {
|
||||
|
||||
inflectionPoints.push({
|
||||
date: dailyData[i].date,
|
||||
amount: dailyData[i].amount,
|
||||
type: trend1 > 0 ? 'peak' : 'valley',
|
||||
significance: Math.abs(trend1) + Math.abs(trend2)
|
||||
})
|
||||
if (result.success) {
|
||||
// 콘텐츠 목록 새로고침
|
||||
await loadContents()
|
||||
return { success: true, message: '콘텐츠가 저장되었습니다.' }
|
||||
} else {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '네트워크 오류가 발생했습니다.' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 트렌드 계산
|
||||
const firstWeekAvg = this.calculateMovingAverage(dailyData, 0, 7)
|
||||
const lastWeekAvg = this.calculateMovingAverage(dailyData, dailyData.length - 7, 7)
|
||||
const growthRate = ((lastWeekAvg - firstWeekAvg) / firstWeekAvg) * 100
|
||||
|
||||
let overallTrend = 'stable'
|
||||
if (Math.abs(growthRate) > 5) {
|
||||
overallTrend = growthRate > 0 ? 'increasing' : 'decreasing'
|
||||
// fetchContentList - 기존 호환성 유지
|
||||
const fetchContentList = async (filters = {}) => {
|
||||
return await loadContents(filters)
|
||||
}
|
||||
|
||||
console.log('변곡점 분석 결과:', { inflectionPoints, overallTrend, growthRate })
|
||||
// 진행 중인 콘텐츠 조회
|
||||
const fetchOngoingContents = async (period = 'month') => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await contentService.getOngoingContents(period)
|
||||
|
||||
if (result.success) {
|
||||
ongoingContents.value = result.data
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '네트워크 오류가 발생했습니다.' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 콘텐츠 수정
|
||||
const updateContent = async (contentId, updateData) => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await contentService.updateContent(contentId, updateData)
|
||||
|
||||
if (result.success) {
|
||||
await loadContents()
|
||||
return { success: true, message: '콘텐츠가 수정되었습니다.' }
|
||||
} else {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '네트워크 오류가 발생했습니다.' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 콘텐츠 삭제
|
||||
const deleteContent = async (contentId) => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await contentService.deleteContent(contentId)
|
||||
|
||||
if (result.success) {
|
||||
await loadContents()
|
||||
return { success: true, message: '콘텐츠가 삭제되었습니다.' }
|
||||
} else {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: '네트워크 오류가 발생했습니다.' }
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inflectionPoints: inflectionPoints.slice(0, 5), // 상위 5개만
|
||||
overallTrend,
|
||||
growthRate: Math.round(growthRate * 10) / 10
|
||||
}
|
||||
}
|
||||
// 상태
|
||||
contentList,
|
||||
contents, // ContentManagementView에서 사용
|
||||
ongoingContents,
|
||||
selectedContent,
|
||||
generatedContent,
|
||||
isLoading,
|
||||
|
||||
/**
|
||||
* 이동평균 계산
|
||||
* @param {Array} data - 데이터 배열
|
||||
* @param {number} startIndex - 시작 인덱스
|
||||
* @param {number} period - 기간
|
||||
* @returns {number} 이동평균값
|
||||
*/
|
||||
calculateMovingAverage(data, startIndex, period) {
|
||||
const slice = data.slice(startIndex, startIndex + period)
|
||||
const sum = slice.reduce((acc, item) => acc + item.amount, 0)
|
||||
return sum / slice.length
|
||||
// 컴퓨티드
|
||||
contentCount,
|
||||
ongoingContentCount,
|
||||
|
||||
// 메서드
|
||||
loadContents, // 새로 추가된 메서드
|
||||
generateContent,
|
||||
saveContent,
|
||||
fetchContentList, // 기존 호환성 유지
|
||||
fetchOngoingContents,
|
||||
updateContent,
|
||||
deleteContent
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트용 데이터 준비
|
||||
* @param {Array} yearSales - 연간 매출 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
prepareChartData(yearSales) {
|
||||
if (!yearSales || yearSales.length === 0) {
|
||||
return { labels: [], salesData: [], targetData: [] }
|
||||
}
|
||||
|
||||
// 최근 30일 데이터만 사용 (차트 표시용)
|
||||
const sortedData = [...yearSales]
|
||||
.sort((a, b) => new Date(a.salesDate) - new Date(b.salesDate))
|
||||
.slice(-30)
|
||||
|
||||
const labels = sortedData.map(item => {
|
||||
const date = new Date(item.salesDate)
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
})
|
||||
|
||||
const salesData = sortedData.map(item => parseFloat(item.salesAmount) || 0)
|
||||
|
||||
// 목표 매출 라인 (평균의 110%)
|
||||
const averageSales = salesData.reduce((a, b) => a + b, 0) / salesData.length
|
||||
const targetData = salesData.map(() => Math.round(averageSales * 1.1))
|
||||
|
||||
return {
|
||||
labels,
|
||||
salesData,
|
||||
targetData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
export const storeService = new StoreService()
|
||||
export default storeService
|
||||
|
||||
// 디버깅을 위한 전역 노출 (개발 환경에서만)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.storeService = storeService
|
||||
}
|
||||
@ -7,15 +7,7 @@
|
||||
export const CONTENT_TYPES = {
|
||||
SNS: 'sns',
|
||||
POSTER: 'poster',
|
||||
VIDEO: 'video',
|
||||
BLOG: 'blog',
|
||||
}
|
||||
|
||||
export const CONTENT_TYPE_LABELS = {
|
||||
[CONTENT_TYPES.SNS]: 'SNS 게시물',
|
||||
[CONTENT_TYPES.POSTER]: '홍보 포스터',
|
||||
[CONTENT_TYPES.VIDEO]: '비디오',
|
||||
[CONTENT_TYPES.BLOG]: '블로그 포스트',
|
||||
BLOG: 'blog'
|
||||
}
|
||||
|
||||
// 플랫폼
|
||||
@ -23,191 +15,201 @@ export const PLATFORMS = {
|
||||
INSTAGRAM: 'instagram',
|
||||
NAVER_BLOG: 'naver_blog',
|
||||
FACEBOOK: 'facebook',
|
||||
TWITTER: 'twitter',
|
||||
YOUTUBE: 'youtube',
|
||||
KAKAO: 'kakao',
|
||||
KAKAO_STORY: 'kakao_story'
|
||||
}
|
||||
|
||||
// 플랫폼 라벨
|
||||
export const PLATFORM_LABELS = {
|
||||
[PLATFORMS.INSTAGRAM]: '인스타그램',
|
||||
[PLATFORMS.NAVER_BLOG]: '네이버 블로그',
|
||||
[PLATFORMS.FACEBOOK]: '페이스북',
|
||||
[PLATFORMS.TWITTER]: '트위터',
|
||||
[PLATFORMS.YOUTUBE]: '유튜브',
|
||||
[PLATFORMS.KAKAO]: '카카오',
|
||||
[PLATFORMS.KAKAO_STORY]: '카카오스토리'
|
||||
}
|
||||
|
||||
// 플랫폼 컬러
|
||||
export const PLATFORM_COLORS = {
|
||||
[PLATFORMS.INSTAGRAM]: 'purple',
|
||||
[PLATFORMS.INSTAGRAM]: 'pink',
|
||||
[PLATFORMS.NAVER_BLOG]: 'green',
|
||||
[PLATFORMS.FACEBOOK]: 'blue',
|
||||
[PLATFORMS.TWITTER]: 'light-blue',
|
||||
[PLATFORMS.YOUTUBE]: 'red',
|
||||
[PLATFORMS.KAKAO]: 'yellow',
|
||||
[PLATFORMS.KAKAO_STORY]: 'amber'
|
||||
}
|
||||
|
||||
// 콘텐츠 상태
|
||||
export const CONTENT_STATUS = {
|
||||
DRAFT: 'draft',
|
||||
PUBLISHED: 'published',
|
||||
SCHEDULED: 'scheduled',
|
||||
ARCHIVED: 'archived',
|
||||
FAILED: 'failed',
|
||||
// 플랫폼 아이콘
|
||||
export const PLATFORM_ICONS = {
|
||||
[PLATFORMS.INSTAGRAM]: 'mdi-instagram',
|
||||
[PLATFORMS.NAVER_BLOG]: 'mdi-web',
|
||||
[PLATFORMS.FACEBOOK]: 'mdi-facebook',
|
||||
[PLATFORMS.KAKAO_STORY]: 'mdi-chat'
|
||||
}
|
||||
|
||||
export const CONTENT_STATUS_LABELS = {
|
||||
[CONTENT_STATUS.DRAFT]: '임시저장',
|
||||
[CONTENT_STATUS.PUBLISHED]: '발행됨',
|
||||
[CONTENT_STATUS.SCHEDULED]: '예약됨',
|
||||
[CONTENT_STATUS.ARCHIVED]: '보관됨',
|
||||
[CONTENT_STATUS.FAILED]: '실패',
|
||||
// 플랫폼 사양 정의 (누락된 PLATFORM_SPECS 추가)
|
||||
export const PLATFORM_SPECS = {
|
||||
[PLATFORMS.INSTAGRAM]: {
|
||||
name: '인스타그램',
|
||||
icon: 'mdi-instagram',
|
||||
color: 'pink',
|
||||
maxLength: 2200,
|
||||
hashtags: true,
|
||||
imageRequired: true,
|
||||
format: 'sns'
|
||||
},
|
||||
[PLATFORMS.NAVER_BLOG]: {
|
||||
name: '네이버 블로그',
|
||||
icon: 'mdi-web',
|
||||
color: 'green',
|
||||
maxLength: 5000,
|
||||
hashtags: false,
|
||||
imageRequired: false,
|
||||
format: 'blog'
|
||||
},
|
||||
[PLATFORMS.FACEBOOK]: {
|
||||
name: '페이스북',
|
||||
icon: 'mdi-facebook',
|
||||
color: 'blue',
|
||||
maxLength: 63206,
|
||||
hashtags: true,
|
||||
imageRequired: false,
|
||||
format: 'sns'
|
||||
},
|
||||
[PLATFORMS.KAKAO_STORY]: {
|
||||
name: '카카오스토리',
|
||||
icon: 'mdi-chat',
|
||||
color: 'amber',
|
||||
maxLength: 1000,
|
||||
hashtags: true,
|
||||
imageRequired: false,
|
||||
format: 'sns'
|
||||
}
|
||||
|
||||
export const CONTENT_STATUS_COLORS = {
|
||||
[CONTENT_STATUS.DRAFT]: 'orange',
|
||||
[CONTENT_STATUS.PUBLISHED]: 'success',
|
||||
[CONTENT_STATUS.SCHEDULED]: 'info',
|
||||
[CONTENT_STATUS.ARCHIVED]: 'grey',
|
||||
[CONTENT_STATUS.FAILED]: 'error',
|
||||
}
|
||||
|
||||
// 매장 업종
|
||||
export const BUSINESS_TYPES = {
|
||||
RESTAURANT: 'restaurant',
|
||||
CAFE: 'cafe',
|
||||
SNACK_BAR: 'snack_bar',
|
||||
FAST_FOOD: 'fast_food',
|
||||
BAKERY: 'bakery',
|
||||
DESSERT: 'dessert',
|
||||
CONVENIENCE: 'convenience',
|
||||
OTHER: 'other',
|
||||
}
|
||||
|
||||
export const BUSINESS_TYPE_LABELS = {
|
||||
[BUSINESS_TYPES.RESTAURANT]: '일반음식점',
|
||||
[BUSINESS_TYPES.CAFE]: '카페',
|
||||
[BUSINESS_TYPES.SNACK_BAR]: '분식점',
|
||||
[BUSINESS_TYPES.FAST_FOOD]: '패스트푸드',
|
||||
[BUSINESS_TYPES.BAKERY]: '제과점',
|
||||
[BUSINESS_TYPES.DESSERT]: '디저트카페',
|
||||
[BUSINESS_TYPES.CONVENIENCE]: '편의점',
|
||||
[BUSINESS_TYPES.OTHER]: '기타',
|
||||
}
|
||||
|
||||
// 톤앤매너
|
||||
export const TONE_AND_MANNER = {
|
||||
FRIENDLY: 'friendly',
|
||||
PROFESSIONAL: 'professional',
|
||||
HUMOROUS: 'humorous',
|
||||
ELEGANT: 'elegant',
|
||||
CASUAL: 'casual',
|
||||
TRENDY: 'trendy',
|
||||
}
|
||||
|
||||
export const TONE_AND_MANNER_LABELS = {
|
||||
[TONE_AND_MANNER.FRIENDLY]: '친근함',
|
||||
[TONE_AND_MANNER.PROFESSIONAL]: '전문적',
|
||||
[TONE_AND_MANNER.HUMOROUS]: '유머러스',
|
||||
[TONE_AND_MANNER.ELEGANT]: '고급스러운',
|
||||
[TONE_AND_MANNER.CASUAL]: '캐주얼',
|
||||
[TONE_AND_MANNER.TRENDY]: '트렌디',
|
||||
HUMOROUS: 'humorous'
|
||||
}
|
||||
|
||||
// 감정 강도
|
||||
export const EMOTION_INTENSITY = {
|
||||
CALM: 'calm',
|
||||
NORMAL: 'normal',
|
||||
ENTHUSIASTIC: 'enthusiastic',
|
||||
EXCITING: 'exciting',
|
||||
}
|
||||
|
||||
export const EMOTION_INTENSITY_LABELS = {
|
||||
[EMOTION_INTENSITY.CALM]: '차분함',
|
||||
[EMOTION_INTENSITY.NORMAL]: '보통',
|
||||
[EMOTION_INTENSITY.ENTHUSIASTIC]: '열정적',
|
||||
[EMOTION_INTENSITY.EXCITING]: '과장된',
|
||||
LOW: 'low',
|
||||
MEDIUM: 'medium',
|
||||
HIGH: 'high'
|
||||
}
|
||||
|
||||
// 프로모션 타입
|
||||
export const PROMOTION_TYPES = {
|
||||
DISCOUNT: 'discount',
|
||||
EVENT: 'event',
|
||||
NEW_MENU: 'new_menu',
|
||||
NONE: 'none',
|
||||
DISCOUNT: 'DISCOUNT',
|
||||
EVENT: 'EVENT',
|
||||
NEW_PRODUCT: 'NEW_PRODUCT',
|
||||
REVIEW: 'REVIEW'
|
||||
}
|
||||
|
||||
export const PROMOTION_TYPE_LABELS = {
|
||||
[PROMOTION_TYPES.DISCOUNT]: '할인 정보',
|
||||
[PROMOTION_TYPES.EVENT]: '이벤트 정보',
|
||||
[PROMOTION_TYPES.NEW_MENU]: '신메뉴 알림',
|
||||
[PROMOTION_TYPES.NONE]: '없음',
|
||||
}
|
||||
|
||||
// 이미지 스타일
|
||||
// 사진 스타일
|
||||
export const PHOTO_STYLES = {
|
||||
MODERN: 'modern',
|
||||
CLASSIC: 'classic',
|
||||
EMOTIONAL: 'emotional',
|
||||
VINTAGE: 'vintage',
|
||||
MINIMALIST: 'minimalist',
|
||||
COLORFUL: 'colorful',
|
||||
BRIGHT: 'bright',
|
||||
CALM: 'calm',
|
||||
NATURAL: 'natural'
|
||||
}
|
||||
|
||||
export const PHOTO_STYLE_LABELS = {
|
||||
[PHOTO_STYLES.MODERN]: '모던',
|
||||
[PHOTO_STYLES.CLASSIC]: '클래식',
|
||||
[PHOTO_STYLES.EMOTIONAL]: '감성적',
|
||||
[PHOTO_STYLES.MINIMALIST]: '미니멀',
|
||||
// 콘텐츠 상태
|
||||
export const CONTENT_STATUS = {
|
||||
DRAFT: 'draft',
|
||||
PUBLISHED: 'published',
|
||||
ARCHIVED: 'archived'
|
||||
}
|
||||
|
||||
// 파일 업로드 제한
|
||||
export const FILE_LIMITS = {
|
||||
MAX_SIZE: 10 * 1024 * 1024, // 10MB
|
||||
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
||||
// 타겟 대상
|
||||
export const TARGET_TYPES = {
|
||||
NEW_MENU: 'new_menu',
|
||||
DISCOUNT: 'discount',
|
||||
STORE: 'store',
|
||||
EVENT: 'event'
|
||||
}
|
||||
|
||||
// 타겟 대상 라벨
|
||||
export const TARGET_TYPE_LABELS = {
|
||||
[TARGET_TYPES.NEW_MENU]: '신메뉴',
|
||||
[TARGET_TYPES.DISCOUNT]: '할인 이벤트',
|
||||
[TARGET_TYPES.STORE]: '매장 홍보',
|
||||
[TARGET_TYPES.EVENT]: '일반 이벤트'
|
||||
}
|
||||
|
||||
// 백엔드 플랫폼 매핑 (프론트엔드 -> 백엔드)
|
||||
export const BACKEND_PLATFORM_MAPPING = {
|
||||
[PLATFORMS.INSTAGRAM]: 'INSTAGRAM',
|
||||
[PLATFORMS.NAVER_BLOG]: 'NAVER_BLOG',
|
||||
[PLATFORMS.FACEBOOK]: 'FACEBOOK',
|
||||
[PLATFORMS.KAKAO_STORY]: 'KAKAO_STORY'
|
||||
}
|
||||
|
||||
// 백엔드에서 프론트엔드로 매핑 (백엔드 -> 프론트엔드)
|
||||
export const FRONTEND_PLATFORM_MAPPING = {
|
||||
'INSTAGRAM': PLATFORMS.INSTAGRAM,
|
||||
'NAVER_BLOG': PLATFORMS.NAVER_BLOG,
|
||||
'FACEBOOK': PLATFORMS.FACEBOOK,
|
||||
'KAKAO_STORY': PLATFORMS.KAKAO_STORY
|
||||
}
|
||||
|
||||
// API 응답 상태
|
||||
export const API_STATUS = {
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error',
|
||||
LOADING: 'loading',
|
||||
IDLE: 'idle',
|
||||
LOADING: 'loading'
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
PAGE_SIZE_OPTIONS: [10, 20, 50, 100],
|
||||
// 페이지 크기
|
||||
export const PAGE_SIZES = {
|
||||
SMALL: 10,
|
||||
MEDIUM: 20,
|
||||
LARGE: 50
|
||||
}
|
||||
|
||||
// 정렬 방향
|
||||
export const SORT_DIRECTION = {
|
||||
ASC: 'asc',
|
||||
DESC: 'desc'
|
||||
}
|
||||
|
||||
// 날짜 포맷
|
||||
export const DATE_FORMATS = {
|
||||
DISPLAY: 'YYYY-MM-DD HH:mm',
|
||||
API: 'YYYY-MM-DD',
|
||||
FULL: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
|
||||
// 파일 업로드 제한
|
||||
export const FILE_LIMITS = {
|
||||
MAX_SIZE: 10485760, // 10MB
|
||||
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
MAX_FILES: 5
|
||||
}
|
||||
|
||||
// 콘텐츠 생성 제한
|
||||
export const CONTENT_LIMITS = {
|
||||
TITLE_MAX_LENGTH: 100,
|
||||
DESCRIPTION_MAX_LENGTH: 500,
|
||||
REQUIREMENTS_MAX_LENGTH: 1000,
|
||||
MAX_HASHTAGS: 30
|
||||
}
|
||||
|
||||
// 알림 타입
|
||||
export const NOTIFICATION_TYPES = {
|
||||
SUCCESS: 'success',
|
||||
ERROR: 'error',
|
||||
WARNING: 'warning',
|
||||
INFO: 'info'
|
||||
}
|
||||
|
||||
// 로컬 스토리지 키
|
||||
export const STORAGE_KEYS = {
|
||||
AUTH_TOKEN: 'auth_token',
|
||||
USER_INFO: 'user_info',
|
||||
APP_SETTINGS: 'app_settings',
|
||||
CONTENT_FILTERS: 'content_filters',
|
||||
}
|
||||
|
||||
// 시간 관련 상수
|
||||
export const TIME_FORMATS = {
|
||||
DATE: 'YYYY-MM-DD',
|
||||
DATETIME: 'YYYY-MM-DD HH:mm:ss',
|
||||
TIME: 'HH:mm',
|
||||
}
|
||||
|
||||
export const DATE_RANGES = {
|
||||
TODAY: 'today',
|
||||
WEEK: 'week',
|
||||
MONTH: 'month',
|
||||
QUARTER: 'quarter',
|
||||
YEAR: 'year',
|
||||
ALL: 'all',
|
||||
}
|
||||
|
||||
export const DATE_RANGE_LABELS = {
|
||||
[DATE_RANGES.TODAY]: '오늘',
|
||||
[DATE_RANGES.WEEK]: '최근 1주일',
|
||||
[DATE_RANGES.MONTH]: '최근 1개월',
|
||||
[DATE_RANGES.QUARTER]: '최근 3개월',
|
||||
[DATE_RANGES.YEAR]: '최근 1년',
|
||||
[DATE_RANGES.ALL]: '전체',
|
||||
ACCESS_TOKEN: 'accessToken',
|
||||
REFRESH_TOKEN: 'refreshToken',
|
||||
USER_INFO: 'userInfo',
|
||||
THEME: 'theme',
|
||||
LANGUAGE: 'language'
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,93 +1,94 @@
|
||||
//* src/views/ContentManagementView.vue
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 필터 영역 -->
|
||||
<v-card class="mb-6">
|
||||
<v-card-text>
|
||||
<v-row align="center">
|
||||
<!-- 콘텐츠 타입 필터 - 칩 형태로 변경 -->
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center flex-wrap ga-2">
|
||||
<span class="text-subtitle-2 font-weight-medium mr-2">콘텐츠 타입:</span>
|
||||
<v-chip
|
||||
v-for="type in contentTypeOptions"
|
||||
:key="type.value"
|
||||
:color="selectedContentType === type.value ? type.color : 'default'"
|
||||
:variant="selectedContentType === type.value ? 'flat' : 'outlined'"
|
||||
size="small"
|
||||
class="mr-1 chip-hover"
|
||||
@click="selectContentType(type.value)"
|
||||
>
|
||||
<span class="mr-1">{{ type.emoji }}</span>
|
||||
{{ type.title.replace(type.emoji + ' ', '') }}
|
||||
<span v-if="type.value !== 'all'" class="ml-1">({{ getTypeCount(type.value) }})</span>
|
||||
<span v-else class="ml-1">({{ getTotalCount() }})</span>
|
||||
</v-chip>
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="d-flex align-center mb-6">
|
||||
<v-icon @click="$router.go(-1)" class="mr-3 cursor-pointer">mdi-arrow-left</v-icon>
|
||||
<h1 class="text-h4 font-weight-bold">콘텐츠 관리</h1>
|
||||
</div>
|
||||
|
||||
<!-- 컨트롤 영역 -->
|
||||
<v-row class="mb-4">
|
||||
<!-- 콘텐츠 타입 필터 -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-chip-group
|
||||
v-model="selectedContentType"
|
||||
selected-class="text-white"
|
||||
mandatory
|
||||
class="mb-3"
|
||||
>
|
||||
<v-chip
|
||||
v-for="option in contentTypeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:color="option.color"
|
||||
@click="selectContentType(option.value)"
|
||||
class="mr-2"
|
||||
>
|
||||
<span class="mr-1">{{ option.emoji }}</span>
|
||||
{{ option.title }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</v-col>
|
||||
|
||||
<!-- 검색 및 새콘텐츠생성 버튼 -->
|
||||
<!-- 검색 및 정렬 -->
|
||||
<v-col cols="12" md="6">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<!-- 제목 검색 -->
|
||||
<div class="d-flex gap-3">
|
||||
<!-- 검색 -->
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="제목, 해시태그로 검색"
|
||||
placeholder="제목, 내용, 해시태그로 검색..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
clearable
|
||||
hide-details
|
||||
@update:model-value="applyFilters"
|
||||
clearable
|
||||
@input="applyFilters"
|
||||
class="flex-grow-1"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 액션 버튼 영역 -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-body-2 text-grey-600">
|
||||
총 {{ filteredContents.length }}개 콘텐츠
|
||||
</span>
|
||||
<v-btn
|
||||
v-if="selectedItems.length > 0"
|
||||
color="error"
|
||||
variant="text"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="deleteSelectedItems"
|
||||
class="ml-4"
|
||||
>
|
||||
선택 삭제 ({{ selectedItems.length }})
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 새 콘텐츠 생성 버튼 -->
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="$router.push('/content/create')"
|
||||
class="ml-2"
|
||||
>
|
||||
새 콘텐츠 생성
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 선택된 항목 일괄 작업 -->
|
||||
<div v-if="selectedItems.length > 0" class="mb-4">
|
||||
<v-alert color="info" variant="tonal">
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span>{{ selectedItems.length }}개 항목이 선택됨</span>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="deleteSelectedItems"
|
||||
>
|
||||
선택 항목 삭제
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
<!-- 로딩 상태 -->
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="primary" size="64" />
|
||||
<div class="mt-4 text-body-1">콘텐츠를 불러오는 중...</div>
|
||||
</div>
|
||||
|
||||
<!-- 콘텐츠 목록 -->
|
||||
<v-card>
|
||||
<v-card-text class="pa-0">
|
||||
<div v-if="loading" class="text-center pa-8">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<div class="mt-4">콘텐츠를 불러오는 중...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="paginatedContents.length === 0" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-file-document-outline</v-icon>
|
||||
<!-- 콘텐츠가 없는 경우 -->
|
||||
<div v-else-if="filteredContents.length === 0" class="text-center py-12">
|
||||
<v-icon size="120" color="grey-lighten-2" class="mb-4">mdi-file-document-outline</v-icon>
|
||||
<div class="text-h6 mb-2">표시할 콘텐츠가 없습니다</div>
|
||||
<div class="text-body-2 text-grey-600 mb-4">새로운 콘텐츠를 생성해보세요</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="$router.push('/content/create')"
|
||||
>
|
||||
<v-btn color="primary" @click="$router.push('/content/create')">
|
||||
콘텐츠 생성하기
|
||||
</v-btn>
|
||||
</div>
|
||||
@ -152,7 +153,7 @@
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="text-caption text-truncate grey--text" style="max-width: 400px;">
|
||||
{{ content.content ? content.content.substring(0, 80) + '...' : '' }}
|
||||
{{ content.content ? content.content.substring(0, 100) + '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@ -171,13 +172,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<div class="d-flex ga-1">
|
||||
<v-btn
|
||||
icon="mdi-eye"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="showDetail(content)"
|
||||
/>
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
@ -196,130 +191,132 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
<div v-if="totalPages > 1" class="d-flex justify-center mt-6">
|
||||
<div class="d-flex justify-center mt-6" v-if="totalPages > 1">
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="totalPages"
|
||||
:total-visible="7"
|
||||
color="primary"
|
||||
@update:model-value="scrollToTop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 보기/수정 다이얼로그 -->
|
||||
<!-- 상세/수정 다이얼로그 -->
|
||||
<v-dialog v-model="showDetailDialog" max-width="800px" scrollable>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span>{{ isEditMode ? '콘텐츠 수정' : '콘텐츠 상세 정보' }}</span>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
<span class="text-h6">{{ isEditMode ? '콘텐츠 수정' : '콘텐츠 상세' }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeDialog" />
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4" style="max-height: 70vh;">
|
||||
<v-form ref="editForm" v-model="editFormValid" v-if="selectedContent">
|
||||
<!-- 제목 -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">제목</label>
|
||||
<v-card-text v-if="selectedContent">
|
||||
<v-form ref="editForm" v-model="editFormValid" v-if="isEditMode">
|
||||
<!-- 수정 모드 -->
|
||||
<v-text-field
|
||||
v-if="isEditMode"
|
||||
v-model="editingContent.title"
|
||||
label="제목"
|
||||
:rules="titleRules"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
/>
|
||||
<div v-else class="text-body-1 font-weight-medium">
|
||||
{{ selectedContent.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 플랫폼 (수정 시 비활성화) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">플랫폼</label>
|
||||
<v-chip :color="getPlatformColor(selectedContent.platform)" variant="tonal">
|
||||
{{ getPlatformText(selectedContent.platform) }}
|
||||
</v-chip>
|
||||
<div v-if="isEditMode" class="text-caption text-grey-600 mt-1">
|
||||
플랫폼은 수정할 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="editingContent.content"
|
||||
label="내용"
|
||||
rows="8"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- 홍보 기간 -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">홍보 기간</label>
|
||||
<div v-if="isEditMode" class="d-flex ga-2">
|
||||
<v-text-field
|
||||
v-model="editingContent.hashtags"
|
||||
label="해시태그 (쉼표로 구분)"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
hint="예: #맛집, #신메뉴, #이벤트"
|
||||
/>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="editingContent.startDate"
|
||||
type="date"
|
||||
label="시작일"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="editingContent.endDate"
|
||||
type="date"
|
||||
label="종료일"
|
||||
type="date"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-body-1">
|
||||
{{ formatDateRange(selectedContent.startDate, selectedContent.endDate) }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
|
||||
<div v-else>
|
||||
<!-- 상세 보기 모드 -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">제목</div>
|
||||
<div class="text-body-1">{{ selectedContent.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 내용 (수정 시 비활성화) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">내용</label>
|
||||
<div v-if="isEditMode" class="pa-3 bg-grey-lighten-4 rounded">
|
||||
<div class="text-body-2 text-grey-600 mb-2">
|
||||
내용은 수정할 수 없습니다. 새로 생성해주세요.
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-body-1" style="white-space: pre-wrap;">
|
||||
{{ selectedContent.content }}
|
||||
</div>
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">플랫폼</div>
|
||||
<v-chip :color="getPlatformColor(selectedContent.platform)" size="small" variant="tonal">
|
||||
{{ getPlatformText(selectedContent.platform) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- 해시태그 (수정 시 비활성화) -->
|
||||
<div class="mb-4">
|
||||
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">해시태그</label>
|
||||
<div class="d-flex flex-wrap ga-1">
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">내용</div>
|
||||
<div class="text-body-1 content-preview">{{ selectedContent.content }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4" v-if="selectedContent.hashtags && selectedContent.hashtags.length > 0">
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">해시태그</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<v-chip
|
||||
v-for="tag in selectedContent.hashtags"
|
||||
:key="tag"
|
||||
class="mr-1 mb-1"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
>
|
||||
#{{ tag }}
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="isEditMode" class="text-caption text-grey-600 mt-1">
|
||||
해시태그는 수정할 수 없습니다. 새로 생성해주세요.
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">프로모션 기간</div>
|
||||
<div class="text-body-1">{{ formatDateRange(selectedContent.startDate, selectedContent.endDate) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">상태</div>
|
||||
<v-chip :color="getStatusColor(selectedContent.status)" size="small" variant="tonal">
|
||||
{{ getStatusText(selectedContent.status) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 text-grey-600 mb-1">생성일</div>
|
||||
<div class="text-body-1">{{ formatDateTime(selectedContent.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<!-- 개선된 버튼 영역 -->
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<div v-if="isEditMode" class="d-flex ga-3">
|
||||
<v-card-actions v-if="selectedContent">
|
||||
<div v-if="isEditMode" class="d-flex justify-end w-100 gap-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="grey"
|
||||
@click="cancelEdit"
|
||||
class="px-6"
|
||||
:disabled="updating"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
@ -328,24 +325,23 @@
|
||||
@click="saveEdit"
|
||||
:loading="updating"
|
||||
:disabled="!editFormValid"
|
||||
class="px-6 elevation-2"
|
||||
class="px-6 elevation-1"
|
||||
>
|
||||
저장
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-else class="d-flex ga-3">
|
||||
|
||||
<div v-else class="d-flex justify-end w-100 gap-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
prepend-icon="mdi-pencil"
|
||||
@click="showEditMode"
|
||||
class="px-6 elevation-1"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="confirmDelete(selectedContent)"
|
||||
class="px-6 elevation-1"
|
||||
@ -372,6 +368,7 @@
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContentStore } from '@/store/content'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
/**
|
||||
* 콘텐츠 관리 화면
|
||||
@ -383,6 +380,7 @@ import { useContentStore } from '@/store/content'
|
||||
|
||||
// 스토어 및 라우터
|
||||
const contentStore = useContentStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 반응형 데이터
|
||||
@ -425,17 +423,10 @@ const errorMessage = ref('')
|
||||
|
||||
// 옵션 데이터
|
||||
const contentTypeOptions = [
|
||||
{ title: '📊 전체', value: 'all', color: 'primary', emoji: '📊' },
|
||||
{ title: '📷 Instagram', value: 'instagram', color: 'pink', emoji: '📷' },
|
||||
{ title: '📝 네이버 블로그', value: 'blog', color: 'green', emoji: '📝' },
|
||||
{ title: '🎨 포스터', value: 'poster', color: 'orange', emoji: '🎨' }
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ title: '최신순', value: 'latest' },
|
||||
{ title: '오래된순', value: 'oldest' },
|
||||
{ title: '제목순', value: 'title' },
|
||||
{ title: '조회수순', value: 'views' }
|
||||
{ title: '전체', value: 'all', color: 'primary', emoji: '📊' },
|
||||
{ title: 'Instagram', value: 'instagram', color: 'pink', emoji: '📷' },
|
||||
{ title: '네이버 블로그', value: 'blog', color: 'green', emoji: '📝' },
|
||||
{ title: '포스터', value: 'poster', color: 'orange', emoji: '🎨' }
|
||||
]
|
||||
|
||||
const titleRules = [
|
||||
@ -519,26 +510,73 @@ const totalPages = computed(() => {
|
||||
const loadContents = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await contentStore.loadContents()
|
||||
console.log('=== 콘텐츠 목록 조회 시작 ===')
|
||||
|
||||
// 📋 API 설계서에 따른 쿼리 파라미터 준비
|
||||
const filters = {
|
||||
contentType: selectedContentType.value !== 'all' ? selectedContentType.value : null,
|
||||
platform: getPlatformForAPI(selectedContentType.value),
|
||||
period: 'all', // 기본값
|
||||
sortBy: sortBy.value || 'latest'
|
||||
}
|
||||
|
||||
console.log('API 요청 필터:', filters)
|
||||
|
||||
// 📡 콘텐츠 스토어를 통해 API 호출
|
||||
await contentStore.loadContents(filters)
|
||||
|
||||
console.log('✅ 콘텐츠 로딩 완료, 개수:', contentStore.contents?.length || 0)
|
||||
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 로딩 실패:', error)
|
||||
console.error('❌ 콘텐츠 로딩 실패:', error)
|
||||
showError.value = true
|
||||
errorMessage.value = '콘텐츠를 불러오는데 실패했습니다.'
|
||||
errorMessage.value = error.message || '콘텐츠를 불러오는데 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 플랫폼 값을 API 요청용으로 변환
|
||||
const getPlatformForAPI = (contentType) => {
|
||||
const platformMapping = {
|
||||
'instagram': 'INSTAGRAM',
|
||||
'blog': 'NAVER_BLOG',
|
||||
'poster': 'POSTER',
|
||||
'all': null
|
||||
}
|
||||
return platformMapping[contentType] || null
|
||||
}
|
||||
|
||||
const selectContentType = (type) => {
|
||||
selectedContentType.value = type
|
||||
currentPage.value = 1
|
||||
// 필터 변경 시 정렬 상태 초기화하지 않음 (별도 처리)
|
||||
// 타입 변경 시 다시 로딩
|
||||
loadContents()
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const sortByPromotionDate = () => {
|
||||
// 프로모션 기간 정렬 토글
|
||||
if (promotionSortOrder.value === 'none') {
|
||||
promotionSortOrder.value = 'desc'
|
||||
} else if (promotionSortOrder.value === 'desc') {
|
||||
promotionSortOrder.value = 'asc'
|
||||
} else {
|
||||
promotionSortOrder.value = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectAll.value) {
|
||||
selectedItems.value = paginatedContents.value.map(content => content.id)
|
||||
} else {
|
||||
selectedItems.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
@ -626,274 +664,123 @@ const deleteSelectedItems = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectAll.value) {
|
||||
selectedItems.value = paginatedContents.value.map(content => content.id)
|
||||
} else {
|
||||
selectedItems.value = []
|
||||
// 유틸리티 함수들
|
||||
const getStatusColor = (status) => {
|
||||
const statusColors = {
|
||||
'DRAFT': 'orange',
|
||||
'PUBLISHED': 'green',
|
||||
'SCHEDULED': 'blue',
|
||||
'ARCHIVED': 'grey'
|
||||
}
|
||||
return statusColors[status] || 'grey'
|
||||
}
|
||||
|
||||
const sortByPromotionDate = () => {
|
||||
if (promotionSortOrder.value === 'none') {
|
||||
promotionSortOrder.value = 'asc'
|
||||
} else if (promotionSortOrder.value === 'asc') {
|
||||
promotionSortOrder.value = 'desc'
|
||||
} else {
|
||||
promotionSortOrder.value = 'none'
|
||||
const getStatusText = (status) => {
|
||||
const statusTexts = {
|
||||
'DRAFT': '임시저장',
|
||||
'PUBLISHED': '발행됨',
|
||||
'SCHEDULED': '예약됨',
|
||||
'ARCHIVED': '보관됨'
|
||||
}
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
const getTotalCount = () => {
|
||||
return contentStore.contents?.length || 0
|
||||
}
|
||||
|
||||
const getTypeCount = (type) => {
|
||||
if (type === 'all') return getTotalCount()
|
||||
return contentStore.contents?.filter(content => {
|
||||
// 플랫폼 매핑 처리
|
||||
const platformMapping = {
|
||||
'instagram': ['instagram', 'INSTAGRAM'],
|
||||
'blog': ['blog', 'NAVER_BLOG', 'naver_blog'],
|
||||
'poster': ['poster', 'POSTER']
|
||||
}
|
||||
|
||||
const allowedPlatforms = platformMapping[type] || [type]
|
||||
return allowedPlatforms.includes(content.platform)
|
||||
}).length || 0
|
||||
return statusTexts[status] || status
|
||||
}
|
||||
|
||||
const getPlatformColor = (platform) => {
|
||||
const colors = {
|
||||
'instagram': 'pink',
|
||||
const platformColors = {
|
||||
'INSTAGRAM': 'pink',
|
||||
'blog': 'green',
|
||||
'instagram': 'pink',
|
||||
'NAVER_BLOG': 'green',
|
||||
'poster': 'orange',
|
||||
'POSTER': 'orange'
|
||||
'blog': 'green',
|
||||
'naver_blog': 'green',
|
||||
'POSTER': 'orange',
|
||||
'poster': 'orange'
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
return platformColors[platform] || 'grey'
|
||||
}
|
||||
|
||||
const getPlatformText = (platform) => {
|
||||
const texts = {
|
||||
'instagram': 'Instagram',
|
||||
'INSTAGRAM': 'Instagram',
|
||||
'blog': '네이버 블로그',
|
||||
const platformTexts = {
|
||||
'INSTAGRAM': '인스타그램',
|
||||
'instagram': '인스타그램',
|
||||
'NAVER_BLOG': '네이버 블로그',
|
||||
'poster': '포스터',
|
||||
'POSTER': '포스터'
|
||||
'blog': '네이버 블로그',
|
||||
'naver_blog': '네이버 블로그',
|
||||
'POSTER': '포스터',
|
||||
'poster': '포스터'
|
||||
}
|
||||
return texts[platform] || platform
|
||||
}
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'published': 'success',
|
||||
'draft': 'warning',
|
||||
'archived': 'grey'
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
return platformTexts[platform] || platform
|
||||
}
|
||||
|
||||
const formatDateRange = (startDate, endDate) => {
|
||||
if (!startDate && !endDate) return '-'
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
if (!startDate && !endDate) return '기간 미설정'
|
||||
if (!endDate) return formatDate(startDate) + ' ~'
|
||||
if (!startDate) return '~ ' + formatDate(endDate)
|
||||
return formatDate(startDate) + ' ~ ' + formatDate(endDate)
|
||||
}
|
||||
|
||||
const start = formatDate(startDate)
|
||||
const end = formatDate(endDate)
|
||||
|
||||
if (start && end) {
|
||||
return `${start} ~ ${end}`
|
||||
} else if (start) {
|
||||
return `${start} ~`
|
||||
} else if (end) {
|
||||
return `~ ${end}`
|
||||
}
|
||||
|
||||
return '-'
|
||||
}
|
||||
|
||||
// 라이프사이클
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
try {
|
||||
await contentStore.loadContents()
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 로딩 실패:', error)
|
||||
showError.value = true
|
||||
errorMessage.value = '콘텐츠를 불러오는데 실패했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch (error) {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬 변경 감지
|
||||
watch(sortBy, () => {
|
||||
// 일반 정렬 선택 시 프로모션 정렬 초기화
|
||||
promotionSortOrder.value = 'none'
|
||||
})
|
||||
|
||||
// 와처
|
||||
watch(selectedItems, (newVal) => {
|
||||
selectAll.value = newVal.length === paginatedContents.value.length && newVal.length > 0
|
||||
})
|
||||
// 컴포넌트 마운트 시 데이터 로드
|
||||
onMounted(() => {
|
||||
console.log('🔄 ContentManagementView 마운트됨')
|
||||
|
||||
// 프로모션 정렬 상태가 변경될 때 다른 정렬 옵션 리셋
|
||||
watch(promotionSortOrder, (newVal) => {
|
||||
if (newVal !== 'none') {
|
||||
// 프로모션 정렬이 활성화될 때는 다른 정렬 옵션을 비활성화
|
||||
console.log(`프로모션 기간 정렬: ${newVal === 'asc' ? '오름차순' : '내림차순'}`)
|
||||
// 인증 확인
|
||||
if (!authStore.isAuthenticated) {
|
||||
console.log('❌ 인증되지 않은 사용자')
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 콘텐츠 로딩
|
||||
loadContents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 정렬 가능한 헤더 스타일 */
|
||||
.sortable-header {
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.sortable-header:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.sortable-header .v-icon {
|
||||
transition: color 0.2s ease-in-out;
|
||||
.content-preview {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 칩 hover 효과 강화 */
|
||||
.v-chip {
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.v-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.chip-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 선택된 칩 강조 */
|
||||
.v-chip.v-chip--variant-flat {
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* 버튼 hover 효과 */
|
||||
.v-btn {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.v-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 테이블 행 hover 효과 */
|
||||
.v-table tbody tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 카드 hover 효과 */
|
||||
.v-card.cursor-pointer:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* 다이얼로그 버튼 스타일링 */
|
||||
.v-card-actions .v-btn {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.v-card-actions .v-btn.elevation-2:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.v-card-actions .v-btn.elevation-1:hover {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 비활성화된 입력 필드 스타일 */
|
||||
.bg-grey-lighten-4 {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* 테이블 스타일 개선 */
|
||||
.v-table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v-table thead th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.v-table tbody td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 필터 카드 스타일 */
|
||||
.v-card {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.justify-space-between.align-center {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.v-row .v-col {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.v-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.v-table th,
|
||||
.v-table td {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.sortable-header {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 정렬 아이콘 애니메이션 */
|
||||
@keyframes sortActive {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.sortable-header .v-icon[style*="color: rgb(25, 118, 210)"] {
|
||||
animation: sortActive 0.3s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@ -122,7 +122,7 @@
|
||||
<div class="y-axis-labels">
|
||||
<div v-for="(label, i) in yAxisLabels" :key="i"
|
||||
class="y-label"
|
||||
:style="{ bottom: `${i * 20}%` }">
|
||||
:style="{ bottom: `${i * 18}%` }">
|
||||
{{ label }}
|
||||
</div>
|
||||
</div>
|
||||
@ -139,7 +139,7 @@
|
||||
ref="chartCanvas"
|
||||
class="chart-canvas"
|
||||
width="800"
|
||||
height="300"
|
||||
height="600"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="hideTooltip">
|
||||
</canvas>
|
||||
@ -176,7 +176,7 @@
|
||||
|
||||
<!-- X축 라벨 - 데이터 포인트와 동일한 위치에 배치 -->
|
||||
<div class="x-axis-labels mt-3" style="position: relative; height: 20px;">
|
||||
<div class="x-axis-container" style="position: relative; padding-left: 60px; padding-right: 20px;">
|
||||
<div class="x-axis-container" style="position: relative; padding-left: 60px; padding-right: 60px;">
|
||||
<span
|
||||
v-for="(point, index) in chartDataPoints"
|
||||
:key="index"
|
||||
@ -987,6 +987,31 @@ const updateAiRecommendation = (aiData) => {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fallback AI 추천 사용
|
||||
*/
|
||||
const useFallbackAiRecommendation = () => {
|
||||
console.log('Fallback AI 추천 사용')
|
||||
aiRecommendation.value = {
|
||||
emoji: '☀️',
|
||||
title: '여름 시즌 마케팅 전략',
|
||||
sections: {
|
||||
ideas: {
|
||||
title: '1. 기본 추천사항',
|
||||
items: [
|
||||
'계절 메뉴 개발 및 프로모션',
|
||||
'SNS 마케팅 활용',
|
||||
'지역 고객 대상 이벤트 기획'
|
||||
]
|
||||
},
|
||||
costs: {
|
||||
title: '2. 기대 효과',
|
||||
items: ['매출 향상', '고객 만족도 증가'],
|
||||
effects: ['브랜드 인지도 상승', '재방문 고객 증가']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 계산된 속성들 (기존과 동일)
|
||||
const currentChartData = computed(() => chartData.value[chartPeriod.value])
|
||||
@ -995,17 +1020,30 @@ const chartDataPoints = computed(() => {
|
||||
const data = currentChartData.value
|
||||
if (!data || data.length === 0) return []
|
||||
|
||||
const maxSales = Math.max(...data.map(d => Math.max(d.sales, d.target)))
|
||||
const maxValue = Math.max(...data.map(d => Math.max(d.sales, d.target)))
|
||||
|
||||
// Canvas의 실제 padding과 일치하는 좌표 계산
|
||||
const padding = 60 // drawChart에서 사용하는 padding과 동일
|
||||
const canvasWidth = 800 // Canvas width와 동일
|
||||
const canvasHeight = 300 // Canvas height와 동일
|
||||
const chartWidth = canvasWidth - padding * 2
|
||||
const chartHeight = canvasHeight - padding * 2
|
||||
|
||||
return data.map((item, index) => {
|
||||
const chartStartPercent = 8
|
||||
const chartEndPercent = 92
|
||||
const chartWidth = chartEndPercent - chartStartPercent
|
||||
// Canvas에서 그려지는 실제 좌표 계산
|
||||
const canvasX = padding + (index * chartWidth / (data.length - 1))
|
||||
const canvasY = padding + chartHeight - ((item.sales / maxValue) * chartHeight)
|
||||
const targetCanvasY = padding + chartHeight - ((item.target / maxValue) * chartHeight)
|
||||
|
||||
// 백분율로 변환하되, data-points의 padding을 고려
|
||||
const xPercent = (canvasX / canvasWidth) * 100
|
||||
const yPercent = ((canvasHeight - canvasY + padding) / canvasHeight) * 100 - 15
|
||||
const targetYPercent = ((canvasHeight - targetCanvasY + padding) / canvasHeight) * 100 - 15
|
||||
|
||||
return {
|
||||
x: chartStartPercent + (index * chartWidth / (data.length - 1)),
|
||||
y: 10 + ((item.sales / maxSales) * 80),
|
||||
targetY: 10 + ((item.target / maxSales) * 80),
|
||||
x: xPercent,
|
||||
y: yPercent,
|
||||
targetY: targetYPercent,
|
||||
sales: item.sales,
|
||||
target: item.target,
|
||||
label: item.label
|
||||
@ -1193,10 +1231,6 @@ const showDataTooltip = (index, event) => {
|
||||
|
||||
if (!data) return
|
||||
|
||||
// 차트 영역의 위치 계산
|
||||
const chartArea = event.target.closest('.chart-area')
|
||||
const rect = chartArea.getBoundingClientRect()
|
||||
|
||||
// ⚠️ 원본 데이터가 있으면 사용, 없으면 기본 변환 로직 사용
|
||||
let actualSales, actualTarget
|
||||
|
||||
@ -1215,8 +1249,8 @@ const showDataTooltip = (index, event) => {
|
||||
|
||||
tooltip.value = {
|
||||
show: true,
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top - 80,
|
||||
x: event.clientX,
|
||||
y: event.clientY - 80,
|
||||
title: data.label,
|
||||
sales: actualSales,
|
||||
target: actualTarget
|
||||
@ -1453,8 +1487,7 @@ onMounted(async () => {
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
z-index: 0;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.y-label {
|
||||
@ -1466,11 +1499,10 @@ onMounted(async () => {
|
||||
|
||||
.chart-grid {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
right: 0;
|
||||
left: 60px;
|
||||
right: 60px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
@ -1487,16 +1519,14 @@ onMounted(async () => {
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.data-points {
|
||||
position: absolute;
|
||||
left: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
@ -1547,8 +1577,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.chart-tooltip {
|
||||
position: absolute;
|
||||
z-index: 99999;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@ -1566,6 +1596,10 @@ onMounted(async () => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-sales,
|
||||
.tooltip-target {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* AI 추천 카드 새로운 스타일 */
|
||||
.ai-recommend-card {
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
|
||||
|
||||
<!-- 로그인 옵션 -->
|
||||
<div class="d-flex justify-space-between align-center mb-6">
|
||||
<v-checkbox
|
||||
|
||||
235
src/views/PosterCreationView.vue
Normal file
235
src/views/PosterCreationView.vue
Normal file
@ -0,0 +1,235 @@
|
||||
//* src/views/PosterCreationView.vue
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 헤더 -->
|
||||
<div class="d-flex align-center mb-6">
|
||||
<v-btn
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
@click="$router.go(-1)"
|
||||
class="mr-2"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-h4 font-weight-bold">홍보 포스터 생성</h1>
|
||||
<p class="text-subtitle-1 text-grey-600 mt-1">AI를 활용하여 매력적인 홍보 포스터를 생성하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<v-row>
|
||||
<!-- 왼쪽: 입력 폼 -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="elevation-2">
|
||||
<v-card-title class="bg-primary">
|
||||
<v-icon class="mr-2">mdi-form-textbox</v-icon>
|
||||
포스터 정보 입력
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<PosterForm
|
||||
v-model="posterForm"
|
||||
:loading="loading"
|
||||
@generate="generatePoster"
|
||||
@save="savePoster"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- 오른쪽: 미리보기 -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="elevation-2">
|
||||
<v-card-title class="bg-secondary">
|
||||
<v-icon class="mr-2">mdi-image-outline</v-icon>
|
||||
미리보기
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<PosterPreview
|
||||
:poster-data="generatedPoster"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<v-overlay v-model="loading" class="align-center justify-center">
|
||||
<div class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="64"
|
||||
color="primary"
|
||||
/>
|
||||
<div class="text-h6 mt-4">{{ loadingMessage }}</div>
|
||||
<div class="text-body-2 text-grey-600 mt-2">{{ loadingSubMessage }}</div>
|
||||
</div>
|
||||
</v-overlay>
|
||||
|
||||
<!-- 성공/오류 스낵바 -->
|
||||
<v-snackbar v-model="showSuccess" color="success" timeout="4000">
|
||||
<v-icon class="mr-2">mdi-check-circle</v-icon>
|
||||
{{ successMessage }}
|
||||
</v-snackbar>
|
||||
|
||||
<v-snackbar v-model="showError" color="error" timeout="6000">
|
||||
<v-icon class="mr-2">mdi-alert-circle</v-icon>
|
||||
{{ errorMessage }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PosterForm from '@/components/poster/PosterForm.vue'
|
||||
import PosterPreview from '@/components/poster/PosterPreview.vue'
|
||||
import { usePosterStore } from '@/store/poster'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
/**
|
||||
* 홍보 포스터 생성 페이지
|
||||
* - 포스터 정보 입력 폼
|
||||
* - AI 포스터 생성
|
||||
* - 실시간 미리보기
|
||||
* - 포스터 저장
|
||||
*/
|
||||
|
||||
// 스토어 및 라우터
|
||||
const posterStore = usePosterStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 반응형 데이터
|
||||
const loading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const loadingSubMessage = ref('')
|
||||
|
||||
// 폼 데이터
|
||||
const posterForm = ref({
|
||||
storeId: null,
|
||||
title: '',
|
||||
targetAudience: '메뉴',
|
||||
promotionStartDate: null,
|
||||
promotionEndDate: null,
|
||||
eventName: '',
|
||||
imageStyle: '모던',
|
||||
promotionType: '할인 정보',
|
||||
emotionIntensity: '보통',
|
||||
images: [],
|
||||
category: '이벤트',
|
||||
requirement: '',
|
||||
toneAndManner: '전문적',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
photoStyle: '밝고 화사한'
|
||||
})
|
||||
|
||||
// 생성된 포스터 데이터
|
||||
const generatedPoster = ref(null)
|
||||
|
||||
// 메시지 상태
|
||||
const showSuccess = ref(false)
|
||||
const showError = ref(false)
|
||||
const successMessage = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
/**
|
||||
* 포스터 생성
|
||||
*/
|
||||
const generatePoster = async (formData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
loadingMessage.value = 'AI 포스터 생성 중...'
|
||||
loadingSubMessage.value = '멋진 포스터를 만들고 있어요'
|
||||
|
||||
const result = await posterStore.generatePoster({
|
||||
...formData,
|
||||
storeId: authStore.currentStore?.id || 1
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
generatedPoster.value = result.data
|
||||
successMessage.value = '포스터가 성공적으로 생성되었습니다!'
|
||||
showSuccess.value = true
|
||||
} else {
|
||||
throw new Error(result.message || '포스터 생성에 실패했습니다.')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('포스터 생성 오류:', error)
|
||||
errorMessage.value = error.message || '포스터 생성 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 저장
|
||||
*/
|
||||
const savePoster = async () => {
|
||||
if (!generatedPoster.value) {
|
||||
errorMessage.value = '저장할 포스터가 없습니다. 먼저 포스터를 생성해주세요.'
|
||||
showError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
loadingMessage.value = '포스터 저장 중...'
|
||||
loadingSubMessage.value = '잠시만 기다려주세요'
|
||||
|
||||
const result = await posterStore.savePoster({
|
||||
contentId: generatedPoster.value.contentId,
|
||||
storeId: authStore.currentStore?.id || 1,
|
||||
title: posterForm.value.title,
|
||||
content: generatedPoster.value.content,
|
||||
images: [generatedPoster.value.posterImage],
|
||||
status: 'PUBLISHED',
|
||||
category: posterForm.value.category,
|
||||
requirement: posterForm.value.requirement,
|
||||
toneAndManner: posterForm.value.toneAndManner,
|
||||
emotionIntensity: posterForm.value.emotionIntensity,
|
||||
eventName: posterForm.value.eventName,
|
||||
startDate: posterForm.value.startDate,
|
||||
endDate: posterForm.value.endDate,
|
||||
photoStyle: posterForm.value.photoStyle
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
successMessage.value = '포스터가 성공적으로 저장되었습니다!'
|
||||
showSuccess.value = true
|
||||
|
||||
// 3초 후 콘텐츠 관리 페이지로 이동
|
||||
setTimeout(() => {
|
||||
router.push('/content')
|
||||
}, 3000)
|
||||
} else {
|
||||
throw new Error(result.message || '포스터 저장에 실패했습니다.')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('포스터 저장 오류:', error)
|
||||
errorMessage.value = error.message || '포스터 저장 중 오류가 발생했습니다.'
|
||||
showError.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 컴포넌트 마운트 시 초기화
|
||||
onMounted(() => {
|
||||
// 현재 매장 정보 설정
|
||||
if (authStore.currentStore) {
|
||||
posterForm.value.storeId = authStore.currentStore.id
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card-title {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user