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,80 +79,88 @@ export const useStoreStore = defineStore('store', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ✅ ID 필드 보장
|
||||
*/
|
||||
async fetchMenus() {
|
||||
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
|
||||
// src/store/index.js에서 fetchMenus 부분만 수정
|
||||
|
||||
try {
|
||||
// 매장 정보에서 storeId 가져오기
|
||||
const storeId = this.storeInfo?.storeId
|
||||
if (!storeId) {
|
||||
console.warn('매장 ID가 없습니다. 매장 정보를 먼저 조회해주세요.')
|
||||
return { success: false, message: '매장 정보가 필요합니다', data: [] }
|
||||
/**
|
||||
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ✅ 이미지 필드 매핑 수정
|
||||
*/
|
||||
async fetchMenus() {
|
||||
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
|
||||
|
||||
try {
|
||||
// 매장 정보에서 storeId 가져오기
|
||||
const storeId = this.storeInfo?.storeId
|
||||
if (!storeId) {
|
||||
console.warn('매장 ID가 없습니다. 매장 정보를 먼저 조회해주세요.')
|
||||
return { success: false, message: '매장 정보가 필요합니다', data: [] }
|
||||
}
|
||||
|
||||
// 메뉴 서비스 임포트
|
||||
const { menuService } = await import('@/services/menu')
|
||||
|
||||
console.log('메뉴 목록 API 호출, 매장 ID:', storeId)
|
||||
const result = await menuService.getMenus(storeId)
|
||||
|
||||
console.log('=== Store 스토어: 메뉴 API 응답 분석 ===')
|
||||
console.log('Result:', result)
|
||||
console.log('Result.success:', result.success)
|
||||
console.log('Result.data:', result.data)
|
||||
console.log('Result.message:', result.message)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// ✅ 백엔드 MenuResponse의 필드명에 맞게 매핑 수정
|
||||
const menusWithId = (result.data || []).map(menu => {
|
||||
// ID 필드가 확실히 있도록 보장
|
||||
const menuId = menu.menuId || menu.id
|
||||
|
||||
if (!menuId) {
|
||||
console.warn('⚠️ 메뉴 ID가 없는 항목 발견:', menu)
|
||||
}
|
||||
|
||||
// 메뉴 서비스 임포트
|
||||
const { menuService } = await import('@/services/menu')
|
||||
console.log('메뉴 원본 데이터:', menu) // 디버깅용
|
||||
|
||||
console.log('메뉴 목록 API 호출, 매장 ID:', storeId)
|
||||
const result = await menuService.getMenus(storeId)
|
||||
|
||||
console.log('=== Store 스토어: 메뉴 API 응답 분석 ===')
|
||||
console.log('Result:', result)
|
||||
console.log('Result.success:', result.success)
|
||||
console.log('Result.data:', result.data)
|
||||
console.log('Result.message:', result.message)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// ✅ 메뉴 데이터 ID 필드 보장 처리
|
||||
const menusWithId = (result.data || []).map(menu => {
|
||||
// ID 필드가 확실히 있도록 보장
|
||||
const menuId = menu.menuId || menu.id
|
||||
|
||||
if (!menuId) {
|
||||
console.warn('⚠️ 메뉴 ID가 없는 항목 발견:', menu)
|
||||
}
|
||||
|
||||
return {
|
||||
...menu,
|
||||
id: menuId, // ✅ id 필드 확실히 설정
|
||||
menuId: menuId, // ✅ menuId 필드도 설정
|
||||
// 기타 필드들 보장
|
||||
menuName: menu.menuName || menu.name || '이름 없음',
|
||||
category: menu.category || '기타',
|
||||
price: menu.price || 0,
|
||||
description: menu.description || '',
|
||||
available: menu.available !== undefined ? menu.available : true,
|
||||
recommended: menu.recommended !== undefined ? menu.recommended : false,
|
||||
imageUrl: menu.imageUrl || '/images/menu-placeholder.png'
|
||||
}
|
||||
})
|
||||
|
||||
// 메뉴 목록이 있는 경우
|
||||
console.log('✅ 메뉴 목록 설정 (ID 보장됨):', menusWithId)
|
||||
this.menus = menusWithId
|
||||
return { success: true, data: menusWithId }
|
||||
} else {
|
||||
// 메뉴가 없거나 조회 실패한 경우
|
||||
console.log('⚠️ 메뉴 목록 없음 또는 조회 실패')
|
||||
this.menus = []
|
||||
|
||||
if (result.message === '등록된 메뉴가 없습니다') {
|
||||
return { success: false, message: '등록된 메뉴가 없습니다', data: [] }
|
||||
} else {
|
||||
return { success: false, message: result.message || '메뉴 목록 조회에 실패했습니다', data: [] }
|
||||
}
|
||||
return {
|
||||
...menu,
|
||||
id: menuId, // ✅ id 필드 확실히 설정
|
||||
menuId: menuId, // ✅ menuId 필드도 설정
|
||||
// 기타 필드들 보장
|
||||
menuName: menu.menuName || menu.name || '이름 없음',
|
||||
category: menu.category || '기타',
|
||||
price: menu.price || 0,
|
||||
description: menu.description || '',
|
||||
available: menu.available !== undefined ? menu.available : true,
|
||||
recommended: menu.recommended !== undefined ? menu.recommended : false,
|
||||
// ✅ 이미지 필드 수정: 백엔드는 'image' 필드 사용
|
||||
imageUrl: menu.image || menu.imageUrl || '/images/menu-placeholder.png',
|
||||
image: menu.image || menu.imageUrl, // 백엔드 호환성
|
||||
createdAt: menu.createdAt,
|
||||
updatedAt: menu.updatedAt
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('=== Store 스토어: 메뉴 목록 조회 실패 ===')
|
||||
console.error('Error:', error)
|
||||
})
|
||||
|
||||
this.menus = []
|
||||
return { success: false, message: error.message || '메뉴 목록을 불러오는데 실패했습니다', data: [] }
|
||||
// 메뉴 목록이 있는 경우
|
||||
console.log('✅ 메뉴 목록 설정 (이미지 필드 매핑 완료):', menusWithId)
|
||||
this.menus = menusWithId
|
||||
return { success: true, data: menusWithId }
|
||||
} else {
|
||||
// 메뉴가 없거나 조회 실패한 경우
|
||||
console.log('⚠️ 메뉴 목록 없음 또는 조회 실패')
|
||||
this.menus = []
|
||||
|
||||
if (result.message === '등록된 메뉴가 없습니다') {
|
||||
return { success: false, message: '등록된 메뉴가 없습니다', data: [] }
|
||||
} else {
|
||||
return { success: false, message: result.message || '메뉴 목록 조회에 실패했습니다', data: [] }
|
||||
}
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('=== Store 스토어: 메뉴 목록 조회 실패 ===')
|
||||
console.error('Error:', error)
|
||||
|
||||
this.menus = []
|
||||
return { success: false, message: error.message || '메뉴 목록을 불러오는데 실패했습니다', data: [] }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 매장 등록
|
||||
|
||||
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 || ''
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message || '매장 등록에 실패했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('매장 등록 실패:', error)
|
||||
|
||||
if (error.response) {
|
||||
console.error('응답 상태:', error.response.status)
|
||||
console.error('응답 데이터:', error.response.data)
|
||||
}
|
||||
|
||||
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, '목업 메뉴 목록을 조회했습니다.')
|
||||
}
|
||||
|
||||
// 실제 백엔드 API 호출 시도
|
||||
try {
|
||||
// GET /api/menu?storeId={storeId}
|
||||
const response = await menuApi.get('', {
|
||||
params: { storeId }
|
||||
// 매장 정보 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('메뉴 목록 조회 API 응답:', response.data)
|
||||
|
||||
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
|
||||
}))
|
||||
|
||||
return formatSuccessResponse(menus, '메뉴 목록을 조회했습니다.')
|
||||
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 (apiError) {
|
||||
console.error('백엔드 API 호출 실패:', apiError)
|
||||
} catch (error) {
|
||||
console.error('❌ 매장 정보 조회 실패:', error)
|
||||
throw new Error('매장 정보를 조회할 수 없습니다.')
|
||||
}
|
||||
|
||||
// 백엔드 미구현이나 네트워크 오류 시 목업 데이터 반환
|
||||
if (apiError.response?.status === 404 ||
|
||||
apiError.code === 'ECONNREFUSED' ||
|
||||
apiError.message.includes('Network Error')) {
|
||||
console.warn('백엔드 미구현 - 목업 데이터 반환')
|
||||
if (!storeId) {
|
||||
throw new Error('매장 ID를 찾을 수 없습니다.')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
console.log('사용자 정보:', userInfo)
|
||||
console.log('조회된 storeId:', storeId)
|
||||
|
||||
return formatSuccessResponse(mockMenus, '목업 메뉴 목록을 조회했습니다. (백엔드 미구현)')
|
||||
}
|
||||
// 2단계: 조회된 storeId로 콘텐츠 목록 조회
|
||||
const apiFilters = {
|
||||
platform: filters.platform || null, // 전체, INSTAGRAM, NAVER_BLOG, POSTER 등
|
||||
storeId: storeId,
|
||||
sortBy: filters.sortBy || 'latest'
|
||||
// contentType, period는 백엔드에서 사용하지 않으므로 제외
|
||||
}
|
||||
|
||||
throw apiError
|
||||
console.log('API 요청 필터:', apiFilters)
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
/**
|
||||
* 목업 메뉴 데이터 생성
|
||||
* @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
|
||||
try {
|
||||
let result
|
||||
if (type === 'sns') {
|
||||
result = await contentService.generateSnsContent(formData)
|
||||
} else if (type === 'poster') {
|
||||
result = await contentService.generatePoster(formData)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 목업 연간 매출 데이터 생성
|
||||
* @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
|
||||
if (result.success) {
|
||||
generatedContent.value = result.data
|
||||
return { success: true, data: result.data }
|
||||
} else {
|
||||
return { success: false, error: result.message }
|
||||
}
|
||||
} 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
|
||||
}))
|
||||
|
||||
// 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)
|
||||
})
|
||||
try {
|
||||
let result
|
||||
if (type === 'sns') {
|
||||
result = await contentService.saveSnsContent(contentData)
|
||||
} else if (type === 'poster') {
|
||||
result = await contentService.savePoster(contentData)
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 트렌드 계산
|
||||
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'
|
||||
}
|
||||
|
||||
console.log('변곡점 분석 결과:', { inflectionPoints, overallTrend, growthRate })
|
||||
|
||||
return {
|
||||
inflectionPoints: inflectionPoints.slice(0, 5), // 상위 5개만
|
||||
overallTrend,
|
||||
growthRate: Math.round(growthRate * 10) / 10
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이동평균 계산
|
||||
* @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
|
||||
// fetchContentList - 기존 호환성 유지
|
||||
const fetchContentList = async (filters = {}) => {
|
||||
return await loadContents(filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트용 데이터 준비
|
||||
* @param {Array} yearSales - 연간 매출 데이터
|
||||
* @returns {Object} 차트 데이터
|
||||
*/
|
||||
prepareChartData(yearSales) {
|
||||
if (!yearSales || yearSales.length === 0) {
|
||||
return { labels: [], salesData: [], targetData: [] }
|
||||
}
|
||||
// 진행 중인 콘텐츠 조회
|
||||
const fetchOngoingContents = async (period = 'month') => {
|
||||
isLoading.value = true
|
||||
|
||||
// 최근 30일 데이터만 사용 (차트 표시용)
|
||||
const sortedData = [...yearSales]
|
||||
.sort((a, b) => new Date(a.salesDate) - new Date(b.salesDate))
|
||||
.slice(-30)
|
||||
try {
|
||||
const result = await contentService.getOngoingContents(period)
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
export const storeService = new StoreService()
|
||||
export default storeService
|
||||
// 콘텐츠 수정
|
||||
const updateContent = async (contentId, updateData) => {
|
||||
isLoading.value = true
|
||||
|
||||
// 디버깅을 위한 전역 노출 (개발 환경에서만)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.storeService = storeService
|
||||
}
|
||||
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 {
|
||||
// 상태
|
||||
contentList,
|
||||
contents, // ContentManagementView에서 사용
|
||||
ongoingContents,
|
||||
selectedContent,
|
||||
generatedContent,
|
||||
isLoading,
|
||||
|
||||
// 컴퓨티드
|
||||
contentCount,
|
||||
ongoingContentCount,
|
||||
|
||||
// 메서드
|
||||
loadContents, // 새로 추가된 메서드
|
||||
generateContent,
|
||||
saveContent,
|
||||
fetchContentList, // 기존 호환성 유지
|
||||
fetchOngoingContents,
|
||||
updateContent,
|
||||
deleteContent
|
||||
}
|
||||
})
|
||||
@ -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]: '실패',
|
||||
}
|
||||
|
||||
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]: '기타',
|
||||
// 플랫폼 사양 정의 (누락된 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 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
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
|
||||
<!-- 비밀번호 입력 -->
|
||||
<!-- 비밀번호 입력 -->
|
||||
<v-text-field
|
||||
v-model="credentials.password"
|
||||
label="비밀번호"
|
||||
@ -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