This commit is contained in:
SeoJHeasdw 2025-06-18 15:57:24 +09:00
parent 7be3af6560
commit 64427ef9c4
6 changed files with 1751 additions and 218 deletions

View 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>

View 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>

View File

@ -1,4 +1,4 @@
//* src/services/content.js - 두 파일 완전 통합 버전 //* src/services/content.js - 완전한 파일 (모든 수정사항 포함)
import axios from 'axios' import axios from 'axios'
// runtime-env.js에서 API URL 가져오기 (대체 방식 포함) // runtime-env.js에서 API URL 가져오기 (대체 방식 포함)
@ -106,11 +106,6 @@ class ContentService {
/** /**
* 콘텐츠 목록 조회 (CON-021: 콘텐츠 조회) * 콘텐츠 목록 조회 (CON-021: 콘텐츠 조회)
* @param {Object} filters - 필터 조건 * @param {Object} filters - 필터 조건
* @param {string} filters.platform - 플랫폼 (instagram, blog, poster)
* @param {number} filters.storeId - 매장 ID
* @param {string} filters.contentType - 콘텐츠 타입
* @param {string} filters.period - 조회 기간
* @param {string} filters.sortBy - 정렬 기준
* @returns {Promise<Object>} 콘텐츠 목록 * @returns {Promise<Object>} 콘텐츠 목록
*/ */
async getContents(filters = {}) { async getContents(filters = {}) {
@ -182,7 +177,7 @@ class ContentService {
} }
/** /**
* SNS 콘텐츠 생성 (CON-019: AI 콘텐츠 생성) * SNS 콘텐츠 생성 (CON-019: AI 콘텐츠 생성) - 수정된 버전
* @param {Object} contentData - 콘텐츠 생성 데이터 * @param {Object} contentData - 콘텐츠 생성 데이터
* @returns {Promise<Object>} 생성된 콘텐츠 * @returns {Promise<Object>} 생성된 콘텐츠
*/ */
@ -190,97 +185,171 @@ class ContentService {
try { try {
console.log('🤖 SNS 콘텐츠 생성 요청:', contentData) console.log('🤖 SNS 콘텐츠 생성 요청:', contentData)
// ✅ 이미지 처리 (SNS는 선택사항) // ✅ contentData 기본 검증
let processedImages = [] if (!contentData || typeof contentData !== 'object') {
if (contentData.images && Array.isArray(contentData.images) && contentData.images.length > 0) { throw new Error('콘텐츠 데이터가 전달되지 않았습니다.')
console.log('📁 [API] SNS 이미지 처리:', contentData.images.length, '개')
processedImages = contentData.images.filter(img => {
const isValid = img && typeof img === 'string' && img.length > 0
console.log('📁 [API] SNS 이미지 유효성:', { isValid, type: typeof img, length: img?.length })
return isValid
})
console.log('📁 [API] SNS 유효 이미지:', processedImages.length, '개')
} }
// ✅ 실제 전달받은 데이터만 사용 (백엔드 API 스펙에 맞춤) // ✅ images 속성 보장 (방어 코드)
const requestData = {} if (!contentData.hasOwnProperty('images')) {
console.warn('⚠️ [API] images 속성이 없음, 빈 배열로 설정')
contentData.images = []
}
if (contentData.storeId !== undefined) requestData.storeId = contentData.storeId if (!Array.isArray(contentData.images)) {
console.warn('⚠️ [API] images가 배열이 아님, 빈 배열로 변환:', typeof contentData.images)
contentData.images = []
}
// ✅ 필수 필드 검증
const requiredFields = ['title', 'platform']
const missingFields = requiredFields.filter(field => !contentData[field])
if (missingFields.length > 0) {
throw new Error(`필수 필드가 누락되었습니다: ${missingFields.join(', ')}`)
}
// ✅ 플랫폼 형식 통일
const normalizeplatform = (platform) => {
const platformMap = {
'INSTAGRAM': 'instagram',
'instagram': 'instagram',
'NAVER_BLOG': 'naver_blog',
'naver_blog': 'naver_blog',
'FACEBOOK': 'facebook',
'facebook': 'facebook',
'KAKAO_STORY': 'kakao_story',
'kakao_story': 'kakao_story'
}
return platformMap[platform] || platform.toLowerCase()
}
// ✅ 카테고리 매핑
const getCategoryFromTargetType = (targetType) => {
const categoryMap = {
'new_menu': '메뉴소개',
'menu': '메뉴소개',
'discount': '이벤트',
'event': '이벤트',
'store': '매장홍보',
'service': '서비스',
'interior': '인테리어',
'daily': '일상',
'review': '고객후기'
}
return categoryMap[targetType] || '기타'
}
// ✅ 요청 데이터 구성
const requestData = {
// 필수 필드들
title: contentData.title.trim(),
platform: normalizeplatform(contentData.platform),
contentType: contentData.contentType || 'sns',
category: contentData.category || getCategoryFromTargetType(contentData.targetType),
images: contentData.images || [] // 기본값 보장
}
// ✅ storeId 처리
if (contentData.storeId !== undefined && contentData.storeId !== null) {
requestData.storeId = contentData.storeId
} else {
try {
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
requestData.storeId = storeInfo.storeId || 1
} catch {
requestData.storeId = 1
}
}
// ✅ 선택적 필드들
if (contentData.storeName) requestData.storeName = contentData.storeName if (contentData.storeName) requestData.storeName = contentData.storeName
if (contentData.storeType) requestData.storeType = contentData.storeType if (contentData.storeType) requestData.storeType = contentData.storeType
if (contentData.platform) requestData.platform = contentData.platform
if (contentData.title) requestData.title = contentData.title
if (contentData.category) requestData.category = contentData.category
if (contentData.requirement || contentData.requirements) { if (contentData.requirement || contentData.requirements) {
requestData.requirement = contentData.requirement || contentData.requirements requestData.requirement = contentData.requirement || contentData.requirements
} }
if (contentData.target || contentData.targetAudience) { if (contentData.target || contentData.targetAudience) {
requestData.target = contentData.target || contentData.targetAudience requestData.target = contentData.target || contentData.targetAudience
} }
if (contentData.contentType) requestData.contentType = contentData.contentType
if (contentData.eventName) requestData.eventName = contentData.eventName if (contentData.eventName) requestData.eventName = contentData.eventName
if (contentData.startDate) requestData.startDate = contentData.startDate if (contentData.startDate) requestData.startDate = contentData.startDate
if (contentData.endDate) requestData.endDate = contentData.endDate if (contentData.endDate) requestData.endDate = contentData.endDate
if (contentData.photoStyle) requestData.photoStyle = contentData.photoStyle
if (contentData.targetAge) requestData.targetAge = contentData.targetAge if (contentData.targetAge) requestData.targetAge = contentData.targetAge
if (contentData.toneAndManner) requestData.toneAndManner = contentData.toneAndManner
if (contentData.emotionalIntensity || contentData.emotionIntensity) { // ✅ 이미지 처리 (contentData.images가 보장됨)
requestData.emotionalIntensity = contentData.emotionalIntensity || contentData.emotionIntensity console.log('📁 [API] 이미지 처리 시작:', contentData.images.length, '개')
const processedImages = contentData.images
.filter(img => img && typeof img === 'string' && img.length > 50)
.map(img => {
if (typeof img === 'string' && img.startsWith('data:image/')) {
return img // Base64 그대로 사용
} else if (typeof img === 'string' && (img.startsWith('http') || img.startsWith('//'))) {
return img // URL 그대로 사용
} else {
console.warn('📁 [API] 알 수 없는 이미지 형식:', img.substring(0, 50))
return img
} }
if (contentData.promotionalType || contentData.promotionType) {
requestData.promotionalType = contentData.promotionalType || contentData.promotionType
}
if (contentData.eventDate) requestData.eventDate = contentData.eventDate
if (contentData.hashtagStyle) requestData.hashtagStyle = contentData.hashtagStyle
if (contentData.hashtagCount) requestData.hashtagCount = contentData.hashtagCount
if (contentData.contentLength) requestData.contentLength = contentData.contentLength
// 이미지는 처리된 것으로 설정
requestData.images = processedImages
// Boolean 필드들 (기본값 처리)
if (contentData.includeHashtags !== undefined) requestData.includeHashtags = contentData.includeHashtags
if (contentData.includeEmojis !== undefined) requestData.includeEmojis = contentData.includeEmojis
if (contentData.includeEmoji !== undefined) requestData.includeEmoji = contentData.includeEmoji
if (contentData.includeCallToAction !== undefined) requestData.includeCallToAction = contentData.includeCallToAction
if (contentData.includeLocation !== undefined) requestData.includeLocation = contentData.includeLocation
if (contentData.forInstagramStory !== undefined) requestData.forInstagramStory = contentData.forInstagramStory
if (contentData.forNaverBlogPost !== undefined) requestData.forNaverBlogPost = contentData.forNaverBlogPost
if (contentData.alternativeTitleCount !== undefined) requestData.alternativeTitleCount = contentData.alternativeTitleCount
if (contentData.alternativeHashtagSetCount !== undefined) requestData.alternativeHashtagSetCount = contentData.alternativeHashtagSetCount
if (contentData.preferredAiModel) requestData.preferredAiModel = contentData.preferredAiModel
console.log('📝 [API] SNS 요청 데이터:', {
...requestData,
images: `${requestData.images.length}개 이미지`
}) })
// 기본 유효성 검사 requestData.images = processedImages
if (!requestData.platform) { console.log('📁 [API] 처리된 이미지:', processedImages.length, '개')
throw new Error('플랫폼은 필수입니다.')
}
if (!requestData.title) { // ✅ 최종 검증
throw new Error('제목은 필수입니다.') console.log('📝 [API] 최종 SNS 요청 데이터:', {
title: requestData.title,
platform: requestData.platform,
category: requestData.category,
contentType: requestData.contentType,
storeId: requestData.storeId,
imageCount: requestData.images.length
})
// ✅ Python AI 서비스 필수 필드 검증
const pythonRequiredFields = ['title', 'category', 'contentType', 'platform', 'images']
const pythonMissingFields = pythonRequiredFields.filter(field => {
if (field === 'images') {
return !Array.isArray(requestData[field])
}
return !requestData[field]
})
if (pythonMissingFields.length > 0) {
console.error('❌ [API] Python AI 서비스 필수 필드 누락:', pythonMissingFields)
throw new Error(`AI 서비스 필수 필드가 누락되었습니다: ${pythonMissingFields.join(', ')}`)
} }
const response = await contentApi.post('/sns/generate', requestData, { const response = await contentApi.post('/sns/generate', requestData, {
timeout: 30000 // 30초 timeout: 30000
}) })
console.log('✅ [API] SNS 콘텐츠 생성 응답:', response.data) console.log('✅ [API] SNS 콘텐츠 생성 응답:', response.data)
return formatSuccessResponse(response.data, 'SNS 게시물이 생성되었습니다.') return formatSuccessResponse(response.data, 'SNS 게시물이 생성되었습니다.')
} catch (error) { } catch (error) {
console.error('❌ [API] SNS 콘텐츠 생성 실패:', error) console.error('❌ [API] SNS 콘텐츠 생성 실패:', error)
if (error.response?.status === 400) {
const backendMessage = error.response.data?.message || '요청 데이터가 잘못되었습니다.'
return {
success: false,
message: backendMessage,
error: error.response.data
}
} else if (error.response?.status === 500) {
return {
success: false,
message: 'AI 서비스에서 콘텐츠 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
error: error.response.data
}
}
return handleApiError(error) return handleApiError(error)
} }
} }
/** /**
* 포스터 생성 (CON-020: AI 포스터 생성) - 이미지 처리 강화 상세 검증 * 포스터 생성 (CON-020: AI 포스터 생성) - 수정된 버전
* @param {Object} posterData - 포스터 생성 데이터 * @param {Object} posterData - 포스터 생성 데이터
* @returns {Promise<Object>} 생성된 포스터 * @returns {Promise<Object>} 생성된 포스터
*/ */
@ -532,15 +601,6 @@ class ContentService {
backendMessage = '서버에서 포스터 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.' backendMessage = '서버에서 포스터 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
} }
// 유효성 검사 오류가 있다면 추출
if (error.response.data && error.response.data.errors) {
console.error('❌ [API] 유효성 검사 오류:', error.response.data.errors)
const validationMessages = Object.values(error.response.data.errors).flat()
if (validationMessages.length > 0) {
backendMessage = validationMessages.join(', ')
}
}
return { return {
success: false, success: false,
message: backendMessage, message: backendMessage,
@ -565,6 +625,37 @@ class ContentService {
} }
} }
/**
* 통합 콘텐츠 생성 (타입에 따라 SNS 또는 포스터 생성) - 수정된 버전
* @param {Object} contentData - 콘텐츠 생성 데이터
* @returns {Promise<Object>} 생성 결과
*/
async generateContent(contentData) {
console.log('🎯 [API] 통합 콘텐츠 생성:', contentData)
// ✅ contentData 유효성 검사 추가
if (!contentData || typeof contentData !== 'object') {
console.error('❌ [API] contentData가 유효하지 않음:', contentData)
return {
success: false,
message: '콘텐츠 데이터가 유효하지 않습니다.',
error: 'INVALID_CONTENT_DATA'
}
}
// ✅ images 속성 보장
if (!Array.isArray(contentData.images)) {
console.warn('⚠️ [API] images 속성이 배열이 아님, 빈 배열로 초기화:', contentData.images)
contentData.images = []
}
if (contentData.contentType === 'poster' || contentData.type === 'poster') {
return await this.generatePoster(contentData)
} else {
return await this.generateSnsContent(contentData)
}
}
/** /**
* SNS 콘텐츠 저장 (CON-010: SNS 게시물 저장) * SNS 콘텐츠 저장 (CON-010: SNS 게시물 저장)
* @param {Object} saveData - 저장할 콘텐츠 데이터 * @param {Object} saveData - 저장할 콘텐츠 데이터
@ -639,21 +730,6 @@ class ContentService {
} }
} }
/**
* 통합 콘텐츠 생성 (타입에 따라 SNS 또는 포스터 생성)
* @param {Object} contentData - 콘텐츠 생성 데이터
* @returns {Promise<Object>} 생성 결과
*/
async generateContent(contentData) {
console.log('🎯 [API] 통합 콘텐츠 생성:', contentData)
if (contentData.contentType === 'poster' || contentData.type === 'poster') {
return await this.generatePoster(contentData)
} else {
return await this.generateSnsContent(contentData)
}
}
/** /**
* 진행 중인 콘텐츠 조회 * 진행 중인 콘텐츠 조회
* @param {string} period - 조회 기간 * @param {string} period - 조회 기간
@ -662,7 +738,6 @@ class ContentService {
async getOngoingContents(period = 'month') { async getOngoingContents(period = 'month') {
try { try {
const response = await contentApi.get(`/ongoing?period=${period}`) const response = await contentApi.get(`/ongoing?period=${period}`)
return formatSuccessResponse(response.data.data, '진행 중인 콘텐츠를 조회했습니다.') return formatSuccessResponse(response.data.data, '진행 중인 콘텐츠를 조회했습니다.')
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
@ -677,7 +752,6 @@ class ContentService {
async getContentDetail(contentId) { async getContentDetail(contentId) {
try { try {
const response = await contentApi.get(`/${contentId}`) const response = await contentApi.get(`/${contentId}`)
return formatSuccessResponse(response.data.data, '콘텐츠 상세 정보를 조회했습니다.') return formatSuccessResponse(response.data.data, '콘텐츠 상세 정보를 조회했습니다.')
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
@ -708,7 +782,6 @@ class ContentService {
if (updateData.images) requestData.images = updateData.images if (updateData.images) requestData.images = updateData.images
const response = await contentApi.put(`/${contentId}`, requestData) const response = await contentApi.put(`/${contentId}`, requestData)
return formatSuccessResponse(response.data.data, '콘텐츠가 수정되었습니다.') return formatSuccessResponse(response.data.data, '콘텐츠가 수정되었습니다.')
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
@ -723,7 +796,6 @@ class ContentService {
async deleteContent(contentId) { async deleteContent(contentId) {
try { try {
await contentApi.delete(`/${contentId}`) await contentApi.delete(`/${contentId}`)
return formatSuccessResponse(null, '콘텐츠가 삭제되었습니다.') return formatSuccessResponse(null, '콘텐츠가 삭제되었습니다.')
} catch (error) { } catch (error) {
return handleApiError(error) return handleApiError(error)
@ -866,6 +938,19 @@ class ContentService {
return handleApiError(error) return handleApiError(error)
} }
} }
/**
* 콘텐츠 저장 (통합)
* @param {Object} saveData - 저장할 콘텐츠 데이터
* @returns {Promise<Object>} 저장 결과
*/
async saveContent(saveData) {
if (saveData.contentType === 'poster' || saveData.type === 'poster') {
return await this.savePoster(saveData)
} else {
return await this.saveSnsContent(saveData)
}
}
} }
// 서비스 인스턴스 생성 및 내보내기 // 서비스 인스턴스 생성 및 내보내기

281
src/store/poster.js Normal file
View 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
}
}
})

View File

@ -1,4 +1,3 @@
//* src/views/ContentCreationView.vue
<template> <template>
<v-container fluid class="pa-0" style="height: 100vh; overflow: hidden;"> <v-container fluid class="pa-0" style="height: 100vh; overflow: hidden;">
<!-- 책자 형식 레이아웃 --> <!-- 책자 형식 레이아웃 -->
@ -69,7 +68,7 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- 콘텐츠 생성 --> <!-- 콘텐츠 생성 - SNS와 포스터 통합 -->
<div v-if="selectedType"> <div v-if="selectedType">
<!-- 기본 정보 --> <!-- 기본 정보 -->
<v-card class="mb-4" elevation="1"> <v-card class="mb-4" elevation="1">
@ -79,17 +78,18 @@
<!-- 제목 --> <!-- 제목 -->
<v-text-field <v-text-field
v-model="formData.title" v-model="formData.title"
label="제목" :label="selectedType === 'poster' ? '포스터 제목' : '게시물 제목'"
variant="outlined" variant="outlined"
:rules="titleRules" :rules="titleRules"
required required
density="compact" density="compact"
class="mb-3" class="mb-3"
placeholder="예: 신메뉴 출시 이벤트" :placeholder="selectedType === 'poster' ? '예: 신메뉴 출시 이벤트' : '예: 맛있는 신메뉴 소개'"
/> />
<!-- 플랫폼 선택 --> <!-- 플랫폼 선택 (SNS만) -->
<v-select <v-select
v-if="selectedType === 'sns'"
v-model="formData.platform" v-model="formData.platform"
:items="platformOptions" :items="platformOptions"
label="발행 플랫폼" label="발행 플랫폼"
@ -113,8 +113,8 @@
<!-- 홍보 대상 --> <!-- 홍보 대상 -->
<v-select <v-select
v-model="formData.targetType" v-model="formData.targetType"
:items="targetTypes" :items="getTargetTypes(selectedType)"
label="홍보 대상" :label="selectedType === 'poster' ? '포스터 대상' : '홍보 대상'"
variant="outlined" variant="outlined"
:rules="targetRules" :rules="targetRules"
required required
@ -122,7 +122,7 @@
class="mb-3" class="mb-3"
/> />
<!-- 이벤트명 (홍보 대상이 이벤트인 경우) --> <!-- 이벤트명 -->
<v-text-field <v-text-field
v-if="formData.targetType === 'event'" v-if="formData.targetType === 'event'"
v-model="formData.eventName" v-model="formData.eventName"
@ -134,6 +134,30 @@
placeholder="예: 신메뉴 할인 이벤트" placeholder="예: 신메뉴 할인 이벤트"
/> />
<!-- 프로모션 기간 (포스터만) -->
<v-row v-if="selectedType === 'poster'">
<v-col cols="6">
<v-text-field
v-model="formData.promotionStartDate"
label="홍보 시작일"
type="datetime-local"
variant="outlined"
density="compact"
:rules="promotionStartDateRules"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="formData.promotionEndDate"
label="홍보 종료일"
type="datetime-local"
variant="outlined"
density="compact"
:rules="promotionEndDateRules"
/>
</v-col>
</v-row>
<!-- 이벤트 기간 (이벤트인 경우) --> <!-- 이벤트 기간 (이벤트인 경우) -->
<v-row v-if="formData.targetType === 'event'"> <v-row v-if="formData.targetType === 'event'">
<v-col cols="6"> <v-col cols="6">
@ -161,18 +185,18 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- AI 설정 (백엔드 지원 필드만) --> <!-- AI 설정 -->
<v-card class="mb-4" elevation="1"> <v-card class="mb-4" elevation="1">
<v-card-title class="text-h6 py-3"> <v-card-title class="text-h6 py-3">
<v-icon class="mr-2" color="primary">mdi-robot</v-icon> <v-icon class="mr-2" color="primary">mdi-robot</v-icon>
AI 설정 AI 설정
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<!-- 사진 스타일 --> <!-- 타겟 연령층 -->
<v-select <v-select
v-model="aiOptions.photoStyle" v-model="aiOptions.targetAge"
:items="photoStyleOptions" :items="targetAgeOptions"
label="사진 스타일" label="타겟 연령층"
variant="outlined" variant="outlined"
density="compact" density="compact"
class="mb-3" class="mb-3"
@ -185,27 +209,30 @@
variant="outlined" variant="outlined"
rows="3" rows="3"
density="compact" density="compact"
placeholder="예: 젊은 고객층을 타겟으로 트렌디한 문구를 사용하고 싶어요" :placeholder="selectedType === 'poster' ?
'예: 밝고 활기찬 분위기의 포스터를 만들어주세요' :
'예: 젊은 고객층을 타겟으로 트렌디한 문구를 사용하고 싶어요'"
/> />
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- 사진 업로드 --> <!-- 이미지 업로드 -->
<v-card class="mb-4" elevation="1"> <v-card class="mb-4" elevation="1">
<v-card-title class="text-h6 py-3"> <v-card-title class="text-h6 py-3">
<v-icon class="mr-2" color="primary">mdi-camera</v-icon> <v-icon class="mr-2" color="primary">mdi-camera</v-icon>
사진 업로드 (선택사항) {{ selectedType === 'poster' ? '포스터용 이미지 업로드' : '사진 업로드 (선택사항)' }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<v-file-input <v-file-input
v-model="uploadedFiles" v-model="uploadedFiles"
label="사진을 선택하세요" :label="selectedType === 'poster' ? '포스터에 포함할 이미지를 선택하세요' : '사진을 선택하세요'"
variant="outlined" variant="outlined"
multiple multiple
accept="image/*" accept="image/*"
prepend-icon="mdi-camera" prepend-icon="mdi-camera"
@change="handleFileUpload" @change="handleFileUpload"
density="compact" density="compact"
:rules="selectedType === 'poster' ? imageRequiredRules : []"
/> />
<!-- 미리보기 --> <!-- 미리보기 -->
@ -248,8 +275,8 @@
@click="generateContent" @click="generateContent"
class="px-8" class="px-8"
> >
<v-icon class="mr-2">mdi-robot</v-icon> <v-icon class="mr-2">{{ selectedType === 'poster' ? 'mdi-image' : 'mdi-robot' }}</v-icon>
AI로 콘텐츠 생성하기 {{ selectedType === 'poster' ? 'AI 포스터 생성하기' : 'AI로 콘텐츠 생성하기' }}
</v-btn> </v-btn>
<div v-if="remainingGenerations <= 0" class="text-caption text-error mt-2"> <div v-if="remainingGenerations <= 0" class="text-caption text-error mt-2">
@ -288,7 +315,7 @@
<v-row> <v-row>
<v-col <v-col
v-for="(version, index) in generatedVersions" v-for="(version, index) in generatedVersions"
:key="index" :key="`version-${index}`"
cols="12" cols="12"
class="mb-3" class="mb-3"
> >
@ -357,11 +384,10 @@
{{ currentVersion.title }} {{ currentVersion.title }}
</div> </div>
<!-- 콘텐츠 내용 (HTML 렌더링) --> <!-- 콘텐츠 내용 -->
<div class="text-body-2 mb-3" style="line-height: 1.6;"> <div class="text-body-2 mb-3" style="line-height: 1.6;">
<div v-if="isHtmlContent(currentVersion.content)" <div v-if="isHtmlContent(currentVersion.content)"
class="html-content preview-content"> class="html-content preview-content">
<!-- 미리보기용 축약 HTML -->
<div v-html="truncateHtmlContent(currentVersion.content, 200)"></div> <div v-html="truncateHtmlContent(currentVersion.content, 200)"></div>
<div v-if="currentVersion.content.length > 500" class="text-caption text-grey mt-2"> <div v-if="currentVersion.content.length > 500" class="text-caption text-grey mt-2">
... 보려면 '자세히 보기' 클릭하세요 ... 보려면 '자세히 보기' 클릭하세요
@ -370,7 +396,6 @@
<div v-else>{{ truncateText(currentVersion.content, 150) }}</div> <div v-else>{{ truncateText(currentVersion.content, 150) }}</div>
</div> </div>
<!-- 해시태그 --> <!-- 해시태그 -->
<div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-3"> <div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-3">
<v-chip <v-chip
@ -441,8 +466,7 @@
<v-divider /> <v-divider />
<v-card-text class="pa-4" style="max-height: 500px;"> <v-card-text class="pa-4" style="max-height: 500px;">
<!-- 전체 콘텐츠 -->
<!-- 전체 콘텐츠 (HTML 렌더링) -->
<div class="mb-4"> <div class="mb-4">
<h4 class="text-h6 mb-2">콘텐츠</h4> <h4 class="text-h6 mb-2">콘텐츠</h4>
<div v-if="isHtmlContent(currentVersion.content)" <div v-if="isHtmlContent(currentVersion.content)"
@ -457,8 +481,6 @@
</div> </div>
</div> </div>
<!-- 해시태그 --> <!-- 해시태그 -->
<div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-4"> <div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-4">
<h4 class="text-h6 mb-2">해시태그</h4> <h4 class="text-h6 mb-2">해시태그</h4>
@ -571,7 +593,7 @@ const publishingIndex = ref(-1)
const showDetailDialog = ref(false) const showDetailDialog = ref(false)
const selectedVersion = ref(0) const selectedVersion = ref(0)
const generatedVersions = ref([]) const generatedVersions = ref([])
const remainingGenerations = ref(3) // AI const remainingGenerations = ref(3)
// //
const formData = ref({ const formData = ref({
@ -582,11 +604,13 @@ const formData = ref({
startDate: '', startDate: '',
endDate: '', endDate: '',
requirements: '', requirements: '',
promotionStartDate: '',
promotionEndDate: ''
}) })
// AI ( DTO ) // AI -
const aiOptions = ref({ const aiOptions = ref({
photoStyle: 'bright' // photoStyle targetAge: '20대'
}) })
// //
@ -621,15 +645,31 @@ const targetTypes = [
{ title: '일반 이벤트', value: 'event' } { title: '일반 이벤트', value: 'event' }
] ]
// //
const photoStyleOptions = [ const targetAgeOptions = [
{ title: '밝고 화사한', value: 'bright' }, { title: '10대', value: '10대' },
{ title: '차분하고 세련된', value: 'calm' }, { title: '20대', value: '20대' },
{ title: '빈티지한', value: 'vintage' }, { title: '30대', value: '30대' },
{ title: '모던한', value: 'modern' }, { title: '40대', value: '40대' },
{ title: '자연스러운', value: 'natural' } { title: '50대', value: '50대' },
{ title: '60대 이상', value: '60대 이상' }
] ]
//
const getTargetTypes = (type) => {
if (type === 'poster') {
return [
{ title: '메뉴', value: 'menu' },
{ title: '이벤트', value: 'event' },
{ title: '매장', value: 'store' },
{ title: '서비스', value: 'service' },
{ title: '할인혜택', value: 'discount' }
]
} else {
return targetTypes
}
}
// //
const titleRules = [ const titleRules = [
v => !!v || '제목은 필수입니다', v => !!v || '제목은 필수입니다',
@ -657,6 +697,22 @@ const endDateRules = [
v => !formData.value.startDate || !v || new Date(v) >= new Date(formData.value.startDate) || '종료일은 시작일보다 이후여야 합니다' v => !formData.value.startDate || !v || new Date(v) >= new Date(formData.value.startDate) || '종료일은 시작일보다 이후여야 합니다'
] ]
const imageRequiredRules = [
v => selectedType.value !== 'poster' || (previewImages.value && previewImages.value.length > 0) || '포스터 생성을 위해 최소 1개의 이미지가 필요합니다'
]
const promotionStartDateRules = [
v => selectedType.value !== 'poster' || !!v || '홍보 시작일은 필수입니다'
]
const promotionEndDateRules = [
v => selectedType.value !== 'poster' || !!v || '홍보 종료일은 필수입니다',
v => {
if (selectedType.value !== 'poster' || !v || !formData.value.promotionStartDate) return true
return new Date(v) > new Date(formData.value.promotionStartDate) || '종료일은 시작일보다 늦어야 합니다'
}
]
// Computed // Computed
const currentVersion = computed(() => { const currentVersion = computed(() => {
return generatedVersions.value[selectedVersion.value] || null return generatedVersions.value[selectedVersion.value] || null
@ -665,13 +721,27 @@ const currentVersion = computed(() => {
// //
const selectContentType = (type) => { const selectContentType = (type) => {
selectedType.value = type selectedType.value = type
console.log(`${type} 타입 선택됨`)
} }
const handleFileUpload = () => { const handleFileUpload = (files) => {
console.log('📁 파일 업로드 시작:', files)
//
if (!files || files.length === 0) {
console.log('파일이 선택되지 않음')
return
}
// FileList
const fileArray = Array.from(files)
console.log('📁 변환된 파일 배열:', fileArray)
//
previewImages.value = [] previewImages.value = []
if (uploadedFiles.value && uploadedFiles.value.length > 0) { fileArray.forEach((file, index) => {
uploadedFiles.value.forEach(file => { if (file && file.type && file.type.startsWith('image/')) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
previewImages.value.push({ previewImages.value.push({
@ -680,8 +750,8 @@ const handleFileUpload = () => {
}) })
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
})
} }
})
} }
const removeImage = (index) => { const removeImage = (index) => {
@ -701,54 +771,239 @@ const generateContent = async () => {
} }
try { try {
console.log('🎯 콘텐츠 생성 요청 시작') console.log(`🎯 [UI] ${selectedType.value.toUpperCase()} 콘텐츠 생성 요청 시작`)
// DTO //
const contentData = { console.log('📁 [UI] 현재 이미지 상태:')
// console.log(' - previewImages.value:', previewImages.value)
title: formData.value.title, console.log(' - previewImages 타입:', typeof previewImages.value)
platform: formData.value.platform, // console.log(' - previewImages 배열 여부:', Array.isArray(previewImages.value))
targetType: formData.value.targetType, console.log(' - previewImages 길이:', previewImages.value?.length)
// if (previewImages.value && Array.isArray(previewImages.value)) {
eventName: formData.value.eventName, previewImages.value.forEach((img, index) => {
startDate: formData.value.startDate, console.log(` - 이미지 ${index + 1}:`, {
endDate: formData.value.endDate, exists: !!img,
hasUrl: !!img?.url,
// urlType: typeof img?.url,
requirements: formData.value.requirements, urlLength: img?.url?.length,
category: getCategory(formData.value.targetType), urlPreview: img?.url?.substring(0, 50)
})
// })
images: previewImages.value.map(img => img.url),
// AI (UI )
aiOptions: aiOptions.value,
//
type: selectedType.value
} }
console.log('📤 전송할 데이터:', contentData) // ID
let actualStoreId = 1
try {
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
if (storeInfo.storeId) {
actualStoreId = storeInfo.storeId
} else if (userInfo.storeId) {
actualStoreId = userInfo.storeId
}
} catch (error) {
console.warn('⚠️ [UI] 매장 정보 파싱 실패, 기본값 사용:', actualStoreId)
}
// ( )
const processImages = () => {
const images = []
console.log('📁 [UI] 이미지 처리 시작...')
// previewImages
if (!previewImages.value) {
console.log('📁 [UI] previewImages.value가 null/undefined')
return images
}
if (!Array.isArray(previewImages.value)) {
console.log('📁 [UI] previewImages.value가 배열이 아님:', typeof previewImages.value)
return images
}
if (previewImages.value.length === 0) {
console.log('📁 [UI] previewImages.value가 빈 배열')
return images
}
// ( )
previewImages.value.forEach((img, index) => {
console.log(`📁 [UI] 이미지 ${index + 1} 처리:`)
console.log(' - img 존재:', !!img)
console.log(' - img 타입:', typeof img)
if (!img) {
console.log(' - 결과: null/undefined, 건너뜀')
return
}
let imageUrl = null
// URL ( )
if (typeof img === 'string') {
//
imageUrl = img
console.log(' - 문자열 이미지:', imageUrl.substring(0, 50))
} else if (img.url && typeof img.url === 'string') {
// url
imageUrl = img.url
console.log(' - 객체 이미지 URL:', imageUrl.substring(0, 50))
} else if (img.src && typeof img.src === 'string') {
// src
imageUrl = img.src
console.log(' - 객체 이미지 SRC:', imageUrl.substring(0, 50))
} else {
console.log(' - 알 수 없는 이미지 형태:', Object.keys(img))
return
}
// URL ( )
if (imageUrl && typeof imageUrl === 'string' && imageUrl.length > 20) {
// Base64 HTTP URL
if (imageUrl.startsWith('data:image/') ||
imageUrl.startsWith('http') ||
imageUrl.startsWith('blob:') ||
imageUrl.startsWith('//')) {
images.push(imageUrl)
console.log(` - 결과: 유효한 이미지 추가 (${Math.round(imageUrl.length / 1024)}KB)`)
} else {
console.log(' - 결과: 유효하지 않은 형식, 건너뜀')
}
} else {
console.log(' - 결과: URL이 너무 짧거나 유효하지 않음, 건너뜀')
}
})
console.log(`📁 [UI] 이미지 처리 완료: ${images.length}개 유효 이미지`)
return images
}
//
const processedImages = processImages()
// contentData
let contentData = {
storeId: actualStoreId,
title: (formData.value.title || '').trim(),
contentType: selectedType.value,
type: selectedType.value,
images: processedImages, //
category: '기타',
platform: '',
targetAudience: '',
requirements: formData.value.requirements || '',
eventName: formData.value.eventName || '',
startDate: formData.value.startDate || '',
endDate: formData.value.endDate || '',
targetAge: (aiOptions.value && aiOptions.value.targetAge) ? aiOptions.value.targetAge : '20대'
}
//
if (selectedType.value === 'poster') {
console.log('🎯 [UI] 포스터 생성 데이터 구성')
console.log('📁 [UI] 포스터용 이미지 체크:', {
processedImagesCount: processedImages.length,
previewImagesCount: previewImages.value?.length || 0,
hasImages: processedImages.length > 0
})
// ( )
if (processedImages.length === 0) {
console.error('❌ [UI] 포스터 이미지 없음!')
console.error('❌ [UI] previewImages 상태:', {
exists: !!previewImages.value,
isArray: Array.isArray(previewImages.value),
length: previewImages.value?.length,
content: previewImages.value
})
appStore.showSnackbar('포스터 생성을 위해 최소 1개의 이미지를 업로드해 주세요.', 'warning')
return
}
contentData.targetAudience = formData.value.targetType || 'menu'
contentData.category = getCategory(formData.value.targetType)
if (formData.value.promotionStartDate) {
contentData.promotionStartDate = formData.value.promotionStartDate
}
if (formData.value.promotionEndDate) {
contentData.promotionEndDate = formData.value.promotionEndDate
}
} else {
console.log('🎯 [UI] SNS 생성 데이터 구성')
if (!formData.value.platform) {
appStore.showSnackbar('플랫폼을 선택해주세요.', 'warning')
return
}
contentData.platform = formData.value.platform
contentData.targetType = formData.value.targetType || 'new_menu'
contentData.category = getCategory(formData.value.targetType)
}
//
console.log('📤 [UI] 최종 contentData:', {
type: contentData.type,
title: contentData.title,
platform: contentData.platform,
category: contentData.category,
storeId: contentData.storeId,
imageCount: contentData.images.length,
firstImagePreview: contentData.images[0]?.substring(0, 50)
})
//
if (!contentData.title) {
throw new Error('제목을 입력해주세요.')
}
if (selectedType.value === 'sns' && !contentData.platform) {
throw new Error('플랫폼을 선택해주세요.')
}
if (selectedType.value === 'poster' && contentData.images.length === 0) {
throw new Error('포스터 생성을 위해서는 최소 1개의 이미지가 필요합니다.')
}
// contentData
if (!contentData || typeof contentData !== 'object') {
throw new Error('콘텐츠 데이터 구성에 실패했습니다.')
}
if (!Array.isArray(contentData.images)) {
console.error('❌ [UI] contentData.images가 배열이 아님!')
contentData.images = []
}
// Store
console.log('🚀 [UI] contentStore.generateContent 호출')
const generated = await contentStore.generateContent(contentData) const generated = await contentStore.generateContent(contentData)
// : AI if (!generated || !generated.success) {
let finalContent = generated.content throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.')
}
// let finalContent = generated.content || generated.data?.content || ''
if (previewImages.value.length > 0) {
const imageHtml = previewImages.value.map(img => // SNS
if (selectedType.value === 'sns' && contentData.images && contentData.images.length > 0) {
const imageHtml = contentData.images.map(imageUrl =>
`<div style="margin-bottom: 15px; text-align: center;"> `<div style="margin-bottom: 15px; text-align: center;">
<img src="${img.url}" style="width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);" /> <img src="${imageUrl}" style="width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);" />
</div>` </div>`
).join('') ).join('')
// HTML
if (isHtmlContent(finalContent)) { if (isHtmlContent(finalContent)) {
finalContent = imageHtml + finalContent finalContent = imageHtml + finalContent
} else { } else {
// HTML
finalContent = imageHtml + `<div style="padding: 15px; font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6;">${finalContent.replace(/\n/g, '<br>')}</div>` finalContent = imageHtml + `<div style="padding: 15px; font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6;">${finalContent.replace(/\n/g, '<br>')}</div>`
} }
} }
@ -757,10 +1012,11 @@ const generateContent = async () => {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
...contentData, ...contentData,
content: finalContent, content: finalContent,
hashtags: generated.hashtags || [], hashtags: generated.hashtags || generated.data?.hashtags || [],
createdAt: new Date(), createdAt: new Date(),
status: 'draft', status: 'draft',
uploadedImages: previewImages.value uploadedImages: previewImages.value || [],
platform: contentData.platform || 'POSTER'
} }
generatedVersions.value.push(newContent) generatedVersions.value.push(newContent)
@ -768,8 +1024,10 @@ const generateContent = async () => {
remainingGenerations.value-- remainingGenerations.value--
appStore.showSnackbar(`콘텐츠 버전 ${generatedVersions.value.length}이 생성되었습니다!`, 'success') appStore.showSnackbar(`콘텐츠 버전 ${generatedVersions.value.length}이 생성되었습니다!`, 'success')
} catch (error) { } catch (error) {
console.error('❌ 콘텐츠 생성 실패:', error) console.error('❌ [UI] 콘텐츠 생성 실패:', error)
console.error('❌ [UI] 에러 스택:', error.stack)
appStore.showSnackbar(error.message || '콘텐츠 생성 중 오류가 발생했습니다.', 'error') appStore.showSnackbar(error.message || '콘텐츠 생성 중 오류가 발생했습니다.', 'error')
} }
} }
@ -795,12 +1053,11 @@ const saveVersion = async (index) => {
try { try {
const version = generatedVersions.value[index] const version = generatedVersions.value[index]
// DTO
await contentStore.saveContent({ await contentStore.saveContent({
title: version.title, title: version.title,
content: version.content, content: version.content,
hashtags: version.hashtags, hashtags: version.hashtags,
platform: version.platform, // platform: version.platform,
category: getCategory(version.targetType), category: getCategory(version.targetType),
eventName: version.eventName, eventName: version.eventName,
eventDate: version.eventDate, eventDate: version.eventDate,
@ -812,7 +1069,6 @@ const saveVersion = async (index) => {
appStore.showSnackbar(`버전 ${index + 1}이 성공적으로 저장되었습니다!`, 'success') appStore.showSnackbar(`버전 ${index + 1}이 성공적으로 저장되었습니다!`, 'success')
//
setTimeout(() => { setTimeout(() => {
if (confirm('저장된 콘텐츠를 확인하시겠습니까?')) { if (confirm('저장된 콘텐츠를 확인하시겠습니까?')) {
router.push('/content') router.push('/content')
@ -829,7 +1085,6 @@ const saveVersion = async (index) => {
const copyToClipboard = async (content) => { const copyToClipboard = async (content) => {
try { try {
// HTML
const textToCopy = isHtmlContent(content) ? extractTextFromHtml(content) : content const textToCopy = isHtmlContent(content) ? extractTextFromHtml(content) : content
await navigator.clipboard.writeText(textToCopy) await navigator.clipboard.writeText(textToCopy)
@ -840,19 +1095,16 @@ const copyToClipboard = async (content) => {
} }
} }
// ( + )
const copyFullContent = async (version) => { const copyFullContent = async (version) => {
try { try {
let fullContent = '' let fullContent = ''
// (HTML )
if (isHtmlContent(version.content)) { if (isHtmlContent(version.content)) {
fullContent += extractTextFromHtml(version.content) fullContent += extractTextFromHtml(version.content)
} else { } else {
fullContent += version.content fullContent += version.content
} }
//
if (version.hashtags && version.hashtags.length > 0) { if (version.hashtags && version.hashtags.length > 0) {
fullContent += '\n\n' + version.hashtags.join(' ') fullContent += '\n\n' + version.hashtags.join(' ')
} }
@ -872,7 +1124,6 @@ const getPlatformIcon = (platform) => {
'naver_blog': 'mdi-web', 'naver_blog': 'mdi-web',
'facebook': 'mdi-facebook', 'facebook': 'mdi-facebook',
'kakao_story': 'mdi-chat', 'kakao_story': 'mdi-chat',
//
'INSTAGRAM': 'mdi-instagram', 'INSTAGRAM': 'mdi-instagram',
'NAVER_BLOG': 'mdi-web', 'NAVER_BLOG': 'mdi-web',
'FACEBOOK': 'mdi-facebook', 'FACEBOOK': 'mdi-facebook',
@ -887,7 +1138,6 @@ const getPlatformColor = (platform) => {
'naver_blog': 'green', 'naver_blog': 'green',
'facebook': 'blue', 'facebook': 'blue',
'kakao_story': 'amber', 'kakao_story': 'amber',
//
'INSTAGRAM': 'pink', 'INSTAGRAM': 'pink',
'NAVER_BLOG': 'green', 'NAVER_BLOG': 'green',
'FACEBOOK': 'blue', 'FACEBOOK': 'blue',
@ -902,7 +1152,6 @@ const getPlatformLabel = (platform) => {
'naver_blog': '네이버 블로그', 'naver_blog': '네이버 블로그',
'facebook': '페이스북', 'facebook': '페이스북',
'kakao_story': '카카오스토리', 'kakao_story': '카카오스토리',
//
'INSTAGRAM': '인스타그램', 'INSTAGRAM': '인스타그램',
'NAVER_BLOG': '네이버 블로그', 'NAVER_BLOG': '네이버 블로그',
'FACEBOOK': '페이스북', 'FACEBOOK': '페이스북',
@ -942,50 +1191,40 @@ const formatDateTime = (date) => {
const truncateText = (text, maxLength) => { const truncateText = (text, maxLength) => {
if (!text) return '' if (!text) return ''
// HTML
const textOnly = text.replace(/<[^>]*>/g, '') const textOnly = text.replace(/<[^>]*>/g, '')
if (textOnly.length <= maxLength) return textOnly if (textOnly.length <= maxLength) return textOnly
return textOnly.substring(0, maxLength) + '...' return textOnly.substring(0, maxLength) + '...'
} }
// HTML
const isHtmlContent = (content) => { const isHtmlContent = (content) => {
if (!content) return false if (!content) return false
// HTML HTML
return /<[^>]+>/.test(content) return /<[^>]+>/.test(content)
} }
// HTML
const extractTextFromHtml = (html) => { const extractTextFromHtml = (html) => {
if (!html) return '' if (!html) return ''
// div
const tempDiv = document.createElement('div') const tempDiv = document.createElement('div')
tempDiv.innerHTML = html tempDiv.innerHTML = html
return tempDiv.textContent || tempDiv.innerText || '' return tempDiv.textContent || tempDiv.innerText || ''
} }
// HTML ()
const truncateHtmlContent = (html, maxLength) => { const truncateHtmlContent = (html, maxLength) => {
if (!html) return '' if (!html) return ''
// maxLength
const textContent = extractTextFromHtml(html) const textContent = extractTextFromHtml(html)
if (textContent.length <= maxLength) { if (textContent.length <= maxLength) {
return html return html
} }
// HTML
const tempDiv = document.createElement('div') const tempDiv = document.createElement('div')
tempDiv.innerHTML = html tempDiv.innerHTML = html
// ( )
const firstSection = tempDiv.querySelector('div[style*="background"]') const firstSection = tempDiv.querySelector('div[style*="background"]')
if (firstSection) { if (firstSection) {
return firstSection.outerHTML return firstSection.outerHTML
} }
//
return `<div style="padding: 10px; font-family: 'Noto Sans KR', Arial, sans-serif;">${truncateText(textContent, maxLength)}</div>` return `<div style="padding: 10px; font-family: 'Noto Sans KR', Arial, sans-serif;">${truncateText(textContent, maxLength)}</div>`
} }
@ -1018,11 +1257,6 @@ onMounted(() => {
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%); background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
} }
.sortable-header:hover {
background-color: rgba(0, 0, 0, 0.04);
}
/* 모바일 최적화 */
@media (max-width: 960px) { @media (max-width: 960px) {
.left-panel { .left-panel {
border-right: none; border-right: none;
@ -1039,7 +1273,6 @@ onMounted(() => {
} }
} }
/* HTML 콘텐츠 스타일링 */
:deep(.html-content) { :deep(.html-content) {
font-family: 'Noto Sans KR', Arial, sans-serif; font-family: 'Noto Sans KR', Arial, sans-serif;
} }
@ -1055,31 +1288,15 @@ onMounted(() => {
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
:deep(.html-content br) {
line-height: 1.6;
}
:deep(.html-content span[style*="color"]) {
font-weight: 500;
}
/* 해시태그 스타일 보정 */
:deep(.html-content span[style*="#1DA1F2"]) { :deep(.html-content span[style*="#1DA1F2"]) {
color: #1976d2 !important; /* Vuetify primary 색상으로 조정 */ color: #1976d2 !important;
} }
/* 미리보기 카드 내 HTML 콘텐츠 스타일 */
.preview-card :deep(.html-content) { .preview-card :deep(.html-content) {
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
} }
.preview-card :deep(.html-content div[style*="background"]) {
border-radius: 8px;
overflow: hidden;
}
/* 미리보기 콘텐츠 최대 높이 제한 */
.preview-content { .preview-content {
max-height: 300px; max-height: 300px;
overflow: hidden; overflow: hidden;
@ -1096,14 +1313,4 @@ onMounted(() => {
background: linear-gradient(transparent, white); background: linear-gradient(transparent, white);
pointer-events: none; pointer-events: none;
} }
/* 다이얼로그 내 HTML 콘텐츠 스타일 */
.v-dialog :deep(.html-content) {
max-width: 100%;
overflow-x: auto;
}
.v-dialog :deep(.html-content div[style*="max-width"]) {
max-width: 100% !important;
}
</style> </style>

View 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>