This commit is contained in:
SeoJHeasdw 2025-06-19 13:24:25 +09:00
parent d85bbf903d
commit 68f84ab357
4 changed files with 1063 additions and 1817 deletions

View File

@ -28,43 +28,58 @@
<div v-else class="poster-result"> <div v-else class="poster-result">
<!-- 포스터 이미지 --> <!-- 포스터 이미지 -->
<div class="poster-image-container mb-4"> <div class="poster-image-container mb-4">
<!-- 이미지 URL 유효성 검사 렌더링 -->
<v-img <v-img
:src="posterData.posterImage || '/images/placeholder-poster.jpg'" v-if="getPosterImageUrl()"
:src="getPosterImageUrl()"
:alt="posterData.title" :alt="posterData.title"
cover cover
class="rounded-lg elevation-4" class="rounded-lg elevation-4 poster-image"
style="aspect-ratio: 3/4; max-height: 400px;" style="aspect-ratio: 3/4; max-height: 400px; width: 100%;"
> >
<template v-slot:placeholder> <template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height"> <div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate /> <v-progress-circular indeterminate color="primary" />
<span class="ml-2">이미지 로딩 ...</span>
</div> </div>
</template> </template>
<template v-slot:error> <template v-slot:error>
<div class="d-flex align-center justify-center fill-height bg-grey-lighten-3"> <div class="d-flex flex-column align-center justify-center fill-height bg-grey-lighten-3">
<v-icon size="48" color="grey">mdi-image-broken</v-icon> <v-icon size="48" color="grey" class="mb-2">mdi-image-broken</v-icon>
<span class="text-body-2 text-grey">이미지를 불러올 없습니다</span>
<span class="text-caption text-grey mt-1">{{ getPosterImageUrl() }}</span>
</div> </div>
</template> </template>
</v-img> </v-img>
<!-- 이미지가 없거나 유효하지 않은 경우 -->
<div v-else class="d-flex flex-column align-center justify-center fill-height bg-grey-lighten-4 rounded-lg" style="aspect-ratio: 3/4; max-height: 400px;">
<v-icon size="48" color="grey" class="mb-2">mdi-image-off</v-icon>
<span class="text-body-2 text-grey">포스터 이미지가 없습니다</span>
<span class="text-caption text-grey mt-1" v-if="posterData.posterImage">
URL: {{ posterData.posterImage }}
</span>
</div>
<!-- 이미지 액션 버튼 --> <!-- 이미지 액션 버튼 -->
<div class="image-actions mt-3"> <div class="image-actions mt-4 d-flex gap-2 justify-center">
<v-btn <v-btn
size="small" color="primary"
variant="outlined" variant="outlined"
prepend-icon="mdi-download" prepend-icon="mdi-download"
@click="downloadPoster" @click="downloadPoster"
class="mr-2" :disabled="!getPosterImageUrl()"
> >
다운로드 다운로드
</v-btn> </v-btn>
<v-btn <v-btn
size="small" color="secondary"
variant="outlined" variant="outlined"
prepend-icon="mdi-share-variant" prepend-icon="mdi-share-variant"
@click="sharePoster" @click="sharePoster"
:disabled="!getPosterImageUrl()"
> >
공유 공유
</v-btn> </v-btn>
@ -72,97 +87,95 @@
</div> </div>
<!-- 포스터 정보 --> <!-- 포스터 정보 -->
<v-card variant="outlined" class="mb-4"> <v-card class="mb-4" variant="outlined">
<v-card-title class="text-h6"> <v-card-title class="text-h6">
{{ posterData.title }} {{ posterData.title || '제목 없음' }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<div v-if="posterData.content" class="mb-3"> <div class="poster-details">
<div class="text-subtitle-2 mb-1">포스터 내용:</div> <div v-if="posterData.targetAudience" class="detail-item mb-2">
<div class="text-body-2">{{ posterData.content }}</div> <v-icon class="mr-2" size="small">mdi-target</v-icon>
</div> <span class="text-body-2">
<strong>홍보 대상:</strong> {{ posterData.targetAudience }}
</span>
</div>
<div v-if="posterData.imageStyle" class="detail-item mb-2">
<v-icon class="mr-2" size="small">mdi-palette</v-icon>
<span class="text-body-2">
<strong>이미지 스타일:</strong> {{ posterData.imageStyle }}
</span>
</div>
<div v-if="posterData.category" class="detail-item mb-2">
<v-icon class="mr-2" size="small">mdi-tag</v-icon>
<span class="text-body-2">
<strong>카테고리:</strong> {{ posterData.category }}
</span>
</div>
<div v-if="posterData.promotionStartDate" class="detail-item mb-2">
<v-icon class="mr-2" size="small">mdi-calendar-start</v-icon>
<span class="text-body-2">
<strong>홍보 기간:</strong>
{{ formatDate(posterData.promotionStartDate) }}
<span v-if="posterData.promotionEndDate">
~ {{ formatDate(posterData.promotionEndDate) }}
</span>
</span>
</div>
<div class="d-flex align-center mb-2"> <div v-if="posterData.status" class="detail-item">
<v-chip <v-chip
:color="getStatusColor(posterData.status)" :color="getStatusColor(posterData.status)"
size="small" size="small"
class="mr-2" variant="flat"
> >
{{ getStatusText(posterData.status) }} {{ getStatusText(posterData.status) }}
</v-chip> </v-chip>
<v-chip </div>
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> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- 포스터 사이즈 옵션 --> <!-- 다양한 사이즈 포스터 (있는 경우) -->
<v-card v-if="posterData.posterSizes && Object.keys(posterData.posterSizes).length > 0" variant="outlined"> <v-card v-if="posterData.posterSizes && Object.keys(posterData.posterSizes).length > 0" variant="outlined">
<v-card-title class="text-subtitle-1"> <v-card-title class="text-h6">
<v-icon class="mr-2">mdi-resize</v-icon> <v-icon class="mr-2">mdi-resize</v-icon>
다양한 사이즈 다양한 사이즈
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<v-chip <v-btn
v-for="(url, size) in posterData.posterSizes" v-for="(url, size) in posterData.posterSizes"
:key="size" :key="size"
variant="outlined"
size="small"
@click="viewPosterSize(size, url)" @click="viewPosterSize(size, url)"
class="cursor-pointer" class="cursor-pointer"
variant="outlined"
> >
{{ size }} {{ size }}
</v-chip> </v-btn>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </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> </div>
<!-- 포스터 사이즈 보기 다이얼로그 --> <!-- 사이즈별 포스터 보기 다이얼로그 -->
<v-dialog v-model="showSizeDialog" max-width="600"> <v-dialog v-model="showSizeDialog" max-width="600px">
<v-card> <v-card>
<v-card-title> <v-card-title>
포스터 사이즈: {{ selectedSize }} {{ selectedSize }} 포스터
</v-card-title> </v-card-title>
<v-card-text class="text-center"> <v-card-text>
<v-img <v-img
:src="selectedSizeUrl" :src="selectedSizeUrl"
:alt="`포스터 ${selectedSize}`" cover
contain class="rounded-lg"
max-height="400" style="max-height: 500px;"
/> />
</v-card-text> </v-card-text>
@ -185,7 +198,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
/** /**
* 포스터 미리보기 컴포넌트 * 포스터 미리보기 컴포넌트
@ -211,6 +224,38 @@ const showSizeDialog = ref(false)
const selectedSize = ref('') const selectedSize = ref('')
const selectedSizeUrl = ref('') const selectedSizeUrl = ref('')
/**
* 포스터 이미지 URL 검증 반환
*/
const getPosterImageUrl = () => {
if (!props.posterData) return null
const posterImage = props.posterData.posterImage
//
console.log('🖼️ [PosterPreview] 이미지 URL 검증:', {
posterImage,
type: typeof posterImage,
length: posterImage?.length,
isString: typeof posterImage === 'string',
isValidUrl: posterImage && typeof posterImage === 'string' && posterImage.length > 10
})
// URL
if (posterImage && typeof posterImage === 'string' && posterImage.length > 10) {
// HTTP(S) URL Data URL
if (posterImage.startsWith('http') ||
posterImage.startsWith('data:image/') ||
posterImage.startsWith('blob:') ||
posterImage.startsWith('//')) {
return posterImage
}
}
console.warn('⚠️ [PosterPreview] 유효하지 않은 이미지 URL:', posterImage)
return null
}
/** /**
* 상태 색상 반환 * 상태 색상 반환
*/ */
@ -236,42 +281,83 @@ const getStatusText = (status) => {
} }
/** /**
* 포스터 다운로드 * 날짜 포맷팅
*/ */
const downloadPoster = () => { const formatDate = (dateString) => {
if (!props.posterData?.posterImage) return if (!dateString) return ''
const link = document.createElement('a') try {
link.href = props.posterData.posterImage const date = new Date(dateString)
link.download = `${props.posterData.title || '포스터'}.jpg` return date.toLocaleDateString('ko-KR', {
document.body.appendChild(link) year: 'numeric',
link.click() month: 'long',
document.body.removeChild(link) day: 'numeric'
})
} catch (error) {
return dateString
}
} }
/** /**
* 포스터 공유 * 포스터 다운로드 - URL 검증 추가
*/
const downloadPoster = () => {
const imageUrl = getPosterImageUrl()
if (!imageUrl) {
console.error('❌ [PosterPreview] 다운로드할 이미지 URL이 없습니다')
return
}
console.log('📥 [PosterPreview] 포스터 다운로드 시도:', imageUrl)
try {
const link = document.createElement('a')
link.href = imageUrl
link.download = `${props.posterData.title || '포스터'}.jpg`
link.target = '_blank' //
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
console.log('✅ [PosterPreview] 다운로드 링크 클릭 완료')
} catch (error) {
console.error('❌ [PosterPreview] 다운로드 실패:', error)
}
}
/**
* 포스터 공유 - URL 검증 추가
*/ */
const sharePoster = async () => { const sharePoster = async () => {
if (!props.posterData?.posterImage) return const imageUrl = getPosterImageUrl()
if (!imageUrl) {
console.error('❌ [PosterPreview] 공유할 이미지 URL이 없습니다')
return
}
console.log('🔗 [PosterPreview] 포스터 공유 시도:', imageUrl)
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: props.posterData.title, title: props.posterData.title || '생성된 포스터',
text: '생성된 홍보 포스터를 확인해보세요!', text: '생성된 홍보 포스터를 확인해보세요!',
url: props.posterData.posterImage url: imageUrl
}) })
console.log('✅ [PosterPreview] 공유 완료')
} catch (error) { } catch (error) {
console.log('공유 취소됨') if (error.name !== 'AbortError') {
console.error('❌ [PosterPreview] 공유 실패:', error)
}
} }
} else { } else {
// URL // URL
try { try {
await navigator.clipboard.writeText(props.posterData.posterImage) await navigator.clipboard.writeText(imageUrl)
// ( ) console.log('✅ [PosterPreview] 클립보드 복사 완료')
//
} catch (error) { } catch (error) {
console.error('클립보드 복사 실패:', error) console.error('❌ [PosterPreview] 클립보드 복사 실패:', error)
} }
} }
} }
@ -289,9 +375,12 @@ const viewPosterSize = (size, url) => {
* 선택된 사이즈 포스터 다운로드 * 선택된 사이즈 포스터 다운로드
*/ */
const downloadSelectedSize = () => { const downloadSelectedSize = () => {
if (!selectedSizeUrl.value) return
const link = document.createElement('a') const link = document.createElement('a')
link.href = selectedSizeUrl.value link.href = selectedSizeUrl.value
link.download = `${props.posterData.title || '포스터'}_${selectedSize.value}.jpg` link.download = `${props.posterData.title || '포스터'}_${selectedSize.value}.jpg`
link.target = '_blank'
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
@ -307,12 +396,50 @@ const downloadSelectedSize = () => {
position: relative; position: relative;
} }
.poster-image {
border: 1px solid #e0e0e0;
}
.image-actions { .image-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.detail-item {
display: flex;
align-items: center;
}
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
/* 호버 효과 */
.v-btn:hover {
transform: translateY(-1px);
transition: transform 0.2s ease;
}
/* 이미지 로딩 애니메이션 */
.v-img {
transition: opacity 0.3s ease;
}
/* 상세 정보 스타일링 */
.poster-details .detail-item {
padding: 4px 0;
border-bottom: 1px solid #f5f5f5;
}
.poster-details .detail-item:last-child {
border-bottom: none;
}
</style> </style>

View File

@ -1,4 +1,5 @@
//* 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 가져오기 (대체 방식 포함)
@ -100,7 +101,7 @@ const handleApiError = (error) => {
/** /**
* 콘텐츠 서비스 클래스 - 완전 통합 버전 * 콘텐츠 서비스 클래스 - 완전 통합 버전
* 백엔드 API 설계서와 일치하도록 구현 * Java 백엔드 multipart/form-data API와 연동
*/ */
class ContentService { class ContentService {
/** /**
@ -177,7 +178,46 @@ class ContentService {
} }
/** /**
* SNS 콘텐츠 생성 (CON-019: AI 콘텐츠 생성) - 수정된 버전 * 통합 콘텐츠 생성 (타입에 따라 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 (!contentData.hasOwnProperty('images')) {
console.warn('⚠️ [API] images 속성이 없음, 빈 배열로 설정')
contentData.images = []
}
if (!Array.isArray(contentData.images)) {
console.warn('⚠️ [API] images가 배열이 아님, 빈 배열로 변환:', typeof contentData.images)
contentData.images = []
}
console.log('✅ [API] images 속성 보장 완료:', contentData.images.length, '개')
// 타입에 따른 분기 처리
if (contentData.contentType === 'poster' || contentData.type === 'poster') {
return await this.generatePoster(contentData)
} else {
return await this.generateSnsContent(contentData)
}
}
/**
* multipart/form-data 형식으로 수정된 SNS 콘텐츠 생성
* @param {Object} contentData - 콘텐츠 생성 데이터 * @param {Object} contentData - 콘텐츠 생성 데이터
* @returns {Promise<Object>} 생성된 콘텐츠 * @returns {Promise<Object>} 생성된 콘텐츠
*/ */
@ -185,146 +225,92 @@ class ContentService {
try { try {
console.log('🤖 SNS 콘텐츠 생성 요청:', contentData) console.log('🤖 SNS 콘텐츠 생성 요청:', contentData)
// ✅ contentData 기본 검증 // ✅ Java 백엔드 필수 필드 검증 (SnsContentCreateRequest 기준)
if (!contentData || typeof contentData !== 'object') { if (!contentData.storeId) {
throw new Error('콘텐츠 데이터가 전달되지 않았습니다.') throw new Error('매장 ID는 필수입니다.')
} }
// ✅ images 속성 보장 (방어 코드) if (!contentData.platform) {
if (!contentData.hasOwnProperty('images')) { throw new Error('플랫폼은 필수입니다.')
console.warn('⚠️ [API] images 속성이 없음, 빈 배열로 설정')
contentData.images = []
} }
if (!Array.isArray(contentData.images)) { if (!contentData.title) {
console.warn('⚠️ [API] images가 배열이 아님, 빈 배열로 변환:', typeof contentData.images) throw new Error('콘텐츠 제목은 필수입니다.')
contentData.images = []
} }
// ✅ 필수 필드 검증 // ✅ FormData 생성 (multipart/form-data)
const requiredFields = ['title', 'platform'] const formData = new FormData()
const missingFields = requiredFields.filter(field => !contentData[field])
if (missingFields.length > 0) { // ✅ request JSON 부분 구성 (Java SnsContentCreateRequest DTO에 맞춤)
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 = { const requestData = {
// 필수 필드들 storeId: contentData.storeId || 1,
title: contentData.title.trim(), storeName: contentData.storeName || '샘플 매장',
platform: normalizeplatform(contentData.platform), storeType: contentData.storeType || '음식점',
platform: this.normalizePlatform(contentData.platform),
title: contentData.title,
category: contentData.category || '메뉴소개',
requirement: contentData.requirement || contentData.requirements || `${contentData.title}에 대한 SNS 게시물을 만들어주세요`,
target: contentData.target || contentData.targetAudience || '일반고객',
contentType: contentData.contentType || 'sns', contentType: contentData.contentType || 'sns',
category: contentData.category || getCategoryFromTargetType(contentData.targetType), eventName: contentData.eventName || null,
images: contentData.images || [] // 기본값 보장 startDate: this.convertToJavaDate(contentData.startDate),
endDate: this.convertToJavaDate(contentData.endDate)
} }
// ✅ storeId 처리 // null 값 제거
if (contentData.storeId !== undefined && contentData.storeId !== null) { Object.keys(requestData).forEach(key => {
requestData.storeId = contentData.storeId if (requestData[key] === null || requestData[key] === undefined) {
} else { delete requestData[key]
try {
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
requestData.storeId = storeInfo.storeId || 1
} catch {
requestData.storeId = 1
} }
} })
// ✅ 선택적 필드들 console.log('📝 [API] Java 백엔드용 SNS 요청 데이터:', requestData)
if (contentData.storeName) requestData.storeName = contentData.storeName
if (contentData.storeType) requestData.storeType = contentData.storeType
if (contentData.requirement || contentData.requirements) {
requestData.requirement = contentData.requirement || contentData.requirements
}
if (contentData.target || contentData.targetAudience) {
requestData.target = contentData.target || contentData.targetAudience
}
if (contentData.eventName) requestData.eventName = contentData.eventName
if (contentData.startDate) requestData.startDate = contentData.startDate
if (contentData.endDate) requestData.endDate = contentData.endDate
if (contentData.targetAge) requestData.targetAge = contentData.targetAge
// ✅ 이미지 처리 (contentData.images가 보장됨) // ✅ request를 JSON 문자열로 FormData에 추가
console.log('📁 [API] 이미지 처리 시작:', contentData.images.length, '개') formData.append('request', JSON.stringify(requestData))
const processedImages = contentData.images // ✅ 이미지 파일들을 FormData에 추가 (SNS는 선택적)
.filter(img => img && typeof img === 'string' && img.length > 50) if (contentData.images && contentData.images.length > 0) {
.map(img => { // Base64 이미지를 Blob으로 변환하여 추가
if (typeof img === 'string' && img.startsWith('data:image/')) { for (let i = 0; i < contentData.images.length; i++) {
return img // Base64 그대로 사용 const imageData = contentData.images[i]
} else if (typeof img === 'string' && (img.startsWith('http') || img.startsWith('//'))) { if (typeof imageData === 'string' && imageData.startsWith('data:image/')) {
return img // URL 그대로 사용 try {
} else { const blob = this.base64ToBlob(imageData)
console.warn('📁 [API] 알 수 없는 이미지 형식:', img.substring(0, 50)) formData.append('files', blob, `image_${i}.jpg`)
return img } catch (error) {
console.warn(`⚠️ 이미지 ${i} 변환 실패:`, error)
}
} }
})
requestData.images = processedImages
console.log('📁 [API] 처리된 이미지:', processedImages.length, '개')
// ✅ 최종 검증
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, { console.log('📁 [API] FormData 구성 완료')
timeout: 30000
// ✅ multipart/form-data로 Java 백엔드 API 호출
const response = await contentApi.post('/sns/generate', formData, {
timeout: 30000,
headers: {
'Content-Type': 'multipart/form-data'
}
}) })
console.log('✅ [API] SNS 콘텐츠 생성 응답:', response.data) console.log('✅ [API] SNS 콘텐츠 생성 응답:', response.data)
return formatSuccessResponse(response.data, 'SNS 게시물이 생성되었습니다.')
// ✅ Java 백엔드 ApiResponse 구조에 맞춰 처리
if (response.data && response.data.success && response.data.data) {
return formatSuccessResponse({
content: response.data.data.content,
hashtags: response.data.data.hashtags || []
}, 'SNS 게시물이 생성되었습니다.')
} else if (response.data && response.data.status === 200 && response.data.data) {
return formatSuccessResponse({
content: response.data.data.content,
hashtags: response.data.data.hashtags || []
}, 'SNS 게시물이 생성되었습니다.')
} else {
throw new Error(response.data?.message || 'SNS 콘텐츠 생성에 실패했습니다.')
}
} catch (error) { } catch (error) {
console.error('❌ [API] SNS 콘텐츠 생성 실패:', error) console.error('❌ [API] SNS 콘텐츠 생성 실패:', error)
@ -349,215 +335,96 @@ class ContentService {
} }
/** /**
* 포스터 생성 (CON-020: AI 포스터 생성) - 수정된 버전 * multipart/form-data 형식으로 수정된 포스터 생성
* @param {Object} posterData - 포스터 생성 데이터 * @param {Object} posterData - 포스터 생성 데이터
* @returns {Promise<Object>} 생성포스터 * @returns {Promise<Object>} 생성 결과
*/ */
async generatePoster(posterData) { async generatePoster(posterData) {
try { try {
console.log('🎯 [API] 포스터 생성 요청 받음:', posterData) console.log('🎯 [API] 포스터 생성 요청 받음:', posterData)
// ✅ 1. 이미지 상세 분석 및 검증 // ✅ Java 백엔드 필수 필드 검증 (PosterContentCreateRequest 기준)
console.log('📁 [API] 이미지 상세 분석 시작...') if (!posterData.title) {
console.log('📁 [API] posterData.images 타입:', typeof posterData.images) throw new Error('제목은 필수입니다.')
console.log('📁 [API] posterData.images 배열 여부:', Array.isArray(posterData.images)) }
console.log('📁 [API] posterData.images 길이:', posterData.images?.length)
let processedImages = [] if (!posterData.targetAudience && !posterData.targetType) {
throw new Error('홍보 대상은 필수입니다.')
}
if (posterData.images && Array.isArray(posterData.images) && posterData.images.length > 0) { if (!posterData.promotionStartDate) {
console.log('📁 [API] 원본 이미지 배열 처리 시작...') throw new Error('홍보 시작일은 필수입니다.')
}
// 각 이미지를 개별적으로 검증
posterData.images.forEach((img, index) => { if (!posterData.promotionEndDate) {
console.log(`📁 [API] 이미지 ${index + 1} 분석:`, { throw new Error('홍보 종료일은 필수입니다.')
type: typeof img, }
isString: typeof img === 'string',
length: img?.length, // ✅ FormData 생성 (multipart/form-data)
isNull: img === null, const formData = new FormData()
isUndefined: img === undefined,
isEmpty: img === '', // ✅ request JSON 부분 구성 (Java PosterContentCreateRequest DTO에 맞춤)
isBase64: typeof img === 'string' && img.startsWith('data:image/'), const requestData = {
preview: typeof img === 'string' ? img.substring(0, 50) + '...' : 'Not string' storeId: posterData.storeId || 1,
}) title: posterData.title,
}) targetAudience: posterData.targetAudience || posterData.targetType || posterData.target,
promotionStartDate: this.convertToJavaDateTime(posterData.promotionStartDate || posterData.startDate),
// 유효한 이미지만 필터링 (더 엄격한 검증) promotionEndDate: this.convertToJavaDateTime(posterData.promotionEndDate || posterData.endDate),
processedImages = posterData.images.filter((img, index) => { menuName: posterData.menuName || (posterData.targetType === 'menu' ? posterData.title : null),
const isValid = img && eventName: posterData.eventName || null,
typeof img === 'string' && imageStyle: posterData.imageStyle || '모던',
img.length > 100 && // 최소 길이 체크 (Base64는 보통 매우 길다) category: posterData.category || '이벤트',
(img.startsWith('data:image/') || img.startsWith('http')) requirement: posterData.requirement || posterData.requirements || `${posterData.title}에 대한 포스터를 만들어주세요`,
startDate: this.convertToJavaDate(posterData.startDate),
console.log(`📁 [API] 이미지 ${index + 1} 유효성:`, { endDate: this.convertToJavaDate(posterData.endDate),
isValid, photoStyle: posterData.photoStyle || '밝고 화사한'
reason: !img ? 'null/undefined' : }
typeof img !== 'string' ? 'not string' :
img.length <= 100 ? 'too short' : // null 값 제거
!img.startsWith('data:image/') && !img.startsWith('http') ? 'invalid format' : Object.keys(requestData).forEach(key => {
'valid' if (requestData[key] === null || requestData[key] === undefined) {
}) delete requestData[key]
}
return isValid })
})
console.log('📝 [API] Java 백엔드용 요청 데이터:', requestData)
console.log('📁 [API] 필터링 결과:', {
원본개수: posterData.images.length, // ✅ request를 JSON 문자열로 FormData에 추가
유효개수: processedImages.length, formData.append('request', JSON.stringify(requestData))
제거된개수: posterData.images.length - processedImages.length
}) // ✅ 이미지 파일들을 FormData에 추가
if (posterData.images && posterData.images.length > 0) {
if (processedImages.length === 0) { // Base64 이미지를 Blob으로 변환하여 추가
console.error('❌ [API] 유효한 이미지가 없습니다!') for (let i = 0; i < posterData.images.length; i++) {
console.error('❌ [API] 원본 이미지 상태:', posterData.images.map((img, i) => ({ const imageData = posterData.images[i]
index: i, if (typeof imageData === 'string' && imageData.startsWith('data:image/')) {
type: typeof img, try {
length: img?.length, const blob = this.base64ToBlob(imageData)
preview: typeof img === 'string' ? img.substring(0, 30) : 'not string' formData.append('images', blob, `image_${i}.jpg`)
}))) } catch (error) {
console.warn(`⚠️ 이미지 ${i} 변환 실패:`, error)
throw new Error('유효한 이미지가 없습니다. 이미지를 다시 선택해 주세요.') }
}
} }
} else {
console.warn('⚠️ [API] 이미지가 없거나 유효하지 않음!')
console.warn('⚠️ [API] posterData.images:', posterData.images)
processedImages = []
} }
// ✅ 2. 필수 필드 검증 강화 console.log('📁 [API] FormData 구성 완료')
const validationErrors = []
if (!posterData.title || posterData.title.trim() === '') { // ✅ multipart/form-data로 Java 백엔드 API 호출
validationErrors.push('제목은 필수입니다.') const response = await contentApi.post('/poster/generate', formData, {
} timeout: 60000,
if (!posterData.targetAudience) {
validationErrors.push('홍보 대상은 필수입니다.')
}
if (processedImages.length === 0) {
validationErrors.push('포스터 생성을 위해서는 최소 1개의 유효한 이미지가 필요합니다.')
}
if (validationErrors.length > 0) {
console.error('❌ [API] 유효성 검사 실패:', validationErrors)
throw new Error(validationErrors.join(' '))
}
// ✅ 3. 실제 전달받은 데이터만 사용 (백엔드 API 스펙에 맞춤)
const requestData = {}
// 필수 필드들 (값이 있을 때만 추가)
if (posterData.storeId !== undefined && posterData.storeId !== null) {
requestData.storeId = posterData.storeId
}
if (posterData.title) {
requestData.title = posterData.title.trim()
}
if (posterData.targetAudience || posterData.targetType) {
requestData.targetAudience = posterData.targetAudience || posterData.targetType
}
if (posterData.promotionStartDate) {
requestData.promotionStartDate = posterData.promotionStartDate
}
if (posterData.promotionEndDate) {
requestData.promotionEndDate = posterData.promotionEndDate
}
// 선택적 필드들 (값이 있을 때만 추가)
if (posterData.eventName) {
requestData.eventName = posterData.eventName
}
if (posterData.imageStyle) {
requestData.imageStyle = posterData.imageStyle
}
if (posterData.promotionType || posterData.targetType) {
requestData.promotionType = posterData.promotionType || posterData.targetType
}
if (posterData.emotionIntensity) {
requestData.emotionIntensity = posterData.emotionIntensity
}
// 이미지는 검증된 것만 포함
requestData.images = processedImages
if (posterData.category) {
requestData.category = posterData.category
}
if (posterData.requirement || posterData.requirements) {
requestData.requirement = posterData.requirement || posterData.requirements
}
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
}
if (posterData.targetAge) {
requestData.targetAge = posterData.targetAge
}
console.log('📝 [API] 최종 요청 데이터 구성 완료:')
console.log('📝 [API] 제목:', requestData.title)
console.log('📝 [API] 홍보대상:', requestData.targetAudience)
console.log('📝 [API] 이미지개수:', requestData.images.length)
console.log('📝 [API] 첫번째이미지크기:', requestData.images[0]?.length, 'chars')
console.log('📝 [API] 매장ID:', requestData.storeId)
console.log('📝 [API] 타겟연령:', requestData.targetAge)
// ✅ 4. 최종 요청 데이터 검증
if (!requestData.images || requestData.images.length === 0) {
throw new Error('처리된 이미지가 없습니다. 이미지 업로드를 다시 시도해 주세요.')
}
// JSON 직렬화 테스트
try {
const testJson = JSON.stringify(requestData)
console.log('📝 [API] JSON 직렬화 테스트 성공, 크기:', Math.round(testJson.length / 1024), 'KB')
} catch (jsonError) {
console.error('❌ [API] JSON 직렬화 실패:', jsonError)
throw new Error('요청 데이터 직렬화에 실패했습니다.')
}
console.log('🚀 [API] 백엔드 API 호출 시작:', '/poster/generate')
// ✅ 5. 실제 백엔드 API 호출 (타임아웃 증가)
const response = await contentApi.post('/poster/generate', requestData, {
timeout: 60000, // 60초로 증가 (포스터 생성은 시간이 걸림)
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'multipart/form-data'
} }
}) })
console.log('✅ [API] 포스터 생성 응답 수신:', { console.log('✅ [API] 포스터 생성 응답:', response.data)
status: response.status,
hasData: !!response.data,
dataType: typeof response.data
})
console.log('✅ [API] 응답 데이터:', response.data)
// ✅ 6. 백엔드 응답 구조에 맞춰 처리 // ✅ Java 백엔드 ApiResponse 구조에 맞춰 처리
if (response.data && response.data.success !== false) { if (response.data && response.data.success && response.data.data) {
return formatSuccessResponse(response.data, '홍보 포스터가 생성되었습니다.') return formatSuccessResponse(response.data.data, '홍보 포스터가 생성되었습니다.')
} else if (response.data && response.data.status === 200 && response.data.data) {
return formatSuccessResponse(response.data.data, '홍보 포스터가 생성되었습니다.')
} else { } else {
throw new Error(response.data?.message || '포스터 생성에 실패했습니다.') throw new Error(response.data?.message || '포스터 생성에 실패했습니다.')
} }
@ -565,57 +432,26 @@ class ContentService {
} catch (error) { } catch (error) {
console.error('❌ [API] 포스터 생성 실패:', error) console.error('❌ [API] 포스터 생성 실패:', error)
// ✅ 7. 백엔드 오류 상세 정보 추출 및 분석
if (error.response) { if (error.response) {
console.error('❌ [API] HTTP 응답 오류:') console.error('❌ [API] HTTP 응답 오류:')
console.error(' - Status:', error.response.status) console.error(' - Status:', error.response.status)
console.error(' - Status Text:', error.response.statusText) console.error(' - Data:', error.response.data)
console.error(' - Headers:', error.response.headers)
console.error(' - Data:', JSON.stringify(error.response.data, null, 2))
// 백엔드에서 반환하는 구체적인 오류 메시지 추출 let errorMessage = '서버 오류가 발생했습니다.'
let backendMessage = '서버 오류가 발생했습니다.'
if (error.response.data) { if (error.response.data?.message) {
if (typeof error.response.data === 'string') { errorMessage = error.response.data.message
backendMessage = error.response.data } else if (error.response.data?.error) {
} else if (error.response.data.message) { errorMessage = error.response.data.error
backendMessage = error.response.data.message
} else if (error.response.data.error) {
backendMessage = error.response.data.error
} else if (error.response.data.detail) {
backendMessage = error.response.data.detail
}
}
console.error('❌ [API] 백엔드 오류 메시지:', backendMessage)
// 특정 오류 코드별 처리
if (error.response.status === 400) {
if (backendMessage.includes('이미지') || backendMessage.includes('image')) {
backendMessage = '이미지 처리 중 오류가 발생했습니다. 이미지를 다시 선택해 주세요.'
}
} else if (error.response.status === 413) {
backendMessage = '이미지 파일이 너무 큽니다. 더 작은 이미지를 선택해 주세요.'
} else if (error.response.status === 500) {
backendMessage = '서버에서 포스터 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
} }
return { return {
success: false, success: false,
message: backendMessage, message: errorMessage,
error: error.response.data, error: error.response.data,
statusCode: error.response.status statusCode: error.response.status
} }
} else if (error.request) {
console.error('❌ [API] 네트워크 요청 오류:', error.request)
return {
success: false,
message: '서버에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.',
error: 'NETWORK_ERROR'
}
} else { } else {
console.error('❌ [API] 일반 오류:', error.message)
return { return {
success: false, success: false,
message: error.message || '포스터 생성 중 예상치 못한 오류가 발생했습니다.', message: error.message || '포스터 생성 중 예상치 못한 오류가 발생했습니다.',
@ -626,34 +462,81 @@ class ContentService {
} }
/** /**
* 통합 콘텐츠 생성 (타입에 따라 SNS 또는 포스터 생성) - 수정된 버전 * Base64 이미지를 Blob으로 변환
* @param {Object} contentData - 콘텐츠 생성 데이터 * @param {string} base64Data - Base64 이미지 데이터
* @returns {Promise<Object>} 생성 결과 * @returns {Blob} 변환된 Blob 객체
*/ */
async generateContent(contentData) { base64ToBlob(base64Data) {
console.log('🎯 [API] 통합 콘텐츠 생성:', contentData) const arr = base64Data.split(',')
const mime = arr[0].match(/:(.*?);/)[1]
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
// ✅ contentData 유효성 검사 추가 while (n--) {
if (!contentData || typeof contentData !== 'object') { u8arr[n] = bstr.charCodeAt(n)
console.error('❌ [API] contentData가 유효하지 않음:', contentData) }
return {
success: false, return new Blob([u8arr], { type: mime })
message: '콘텐츠 데이터가 유효하지 않습니다.', }
error: 'INVALID_CONTENT_DATA'
/**
* 날짜를 Java LocalDateTime 형식으로 변환
* @param {string} dateTimeString - 날짜 문자열
* @returns {string} Java LocalDateTime 형식 (yyyy-MM-ddTHH:mm:ss)
*/
convertToJavaDateTime(dateTimeString) {
if (!dateTimeString) return null
try {
// "2025-06-19T09:58" 형식이면 그대로 사용하고 초 추가
if (dateTimeString.includes('T')) {
return dateTimeString.length === 16 ? dateTimeString + ':00' : dateTimeString
} }
// "2025-06-19" 형식이면 시간 추가
return dateTimeString + 'T00:00:00'
} catch (error) {
console.error('❌ DateTime 변환 오류:', error)
return null
} }
}
/**
* 날짜를 Java LocalDate 형식으로 변환
* @param {string} dateString - 날짜 문자열
* @returns {string} Java LocalDate 형식 (yyyy-MM-dd)
*/
convertToJavaDate(dateString) {
if (!dateString) return null
// ✅ images 속성 보장 try {
if (!Array.isArray(contentData.images)) { // "2025-06-19T09:58" -> "2025-06-19"
console.warn('⚠️ [API] images 속성이 배열이 아님, 빈 배열로 초기화:', contentData.images) if (dateString.includes('T')) {
contentData.images = [] return dateString.split('T')[0]
}
// 이미 yyyy-MM-dd 형식이면 그대로 반환
return dateString
} catch (error) {
console.error('❌ Date 변환 오류:', error)
return null
} }
}
if (contentData.contentType === 'poster' || contentData.type === 'poster') {
return await this.generatePoster(contentData) /**
} else { * 플랫폼 이름 정규화
return await this.generateSnsContent(contentData) * @param {string} platform - 플랫폼 이름
* @returns {string} 정규화된 플랫폼 이름
*/
normalizePlatform(platform) {
const platformMap = {
'instagram': 'INSTAGRAM',
'naver_blog': 'NAVER_BLOG',
'facebook': 'FACEBOOK',
'kakao_story': 'KAKAO_STORY'
} }
return platformMap[platform] || platform.toUpperCase()
} }
/** /**
@ -731,16 +614,15 @@ class ContentService {
} }
/** /**
* 진행 중인 콘텐츠 조회 * 콘텐츠 저장 (통합)
* @param {string} period - 조회 기간 * @param {Object} saveData - 저장할 콘텐츠 데이터
* @returns {Promise<Object>} 진행 중인 콘텐츠 목록 * @returns {Promise<Object>} 저장 결과
*/ */
async getOngoingContents(period = 'month') { async saveContent(saveData) {
try { if (saveData.contentType === 'poster' || saveData.type === 'poster') {
const response = await contentApi.get(`/ongoing?period=${period}`) return await this.savePoster(saveData)
return formatSuccessResponse(response.data.data, '진행 중인 콘텐츠를 조회했습니다.') } else {
} catch (error) { return await this.saveSnsContent(saveData)
return handleApiError(error)
} }
} }
@ -803,20 +685,32 @@ class ContentService {
} }
/** /**
* 타겟 타입을 카테고리로 매핑 * 콘텐츠 상태 변경 (추가 기능)
* @param {string} targetType - 타겟 타입 * @param {number} contentId - 콘텐츠 ID
* @returns {string} 매핑된 카테고리 * @param {string} status - 변경할 상태
* @returns {Promise<Object>} 상태 변경 결과
*/ */
mapTargetToCategory(targetType) { async updateContentStatus(contentId, status) {
const mapping = { try {
'new_menu': '메뉴소개', const response = await contentApi.patch(`/${contentId}/status`, { status })
'discount': '이벤트', return formatSuccessResponse(response.data.data, `콘텐츠 상태가 ${status}로 변경되었습니다.`)
'store': '인테리어', } catch (error) {
'event': '이벤트', return handleApiError(error)
'menu': '메뉴소개', }
'service': '서비스' }
/**
* 콘텐츠 복제 (추가 기능)
* @param {number} contentId - 복제할 콘텐츠 ID
* @returns {Promise<Object>} 복제 결과
*/
async duplicateContent(contentId) {
try {
const response = await contentApi.post(`/${contentId}/duplicate`)
return formatSuccessResponse(response.data.data, '콘텐츠가 복제되었습니다.')
} catch (error) {
return handleApiError(error)
} }
return mapping[targetType] || '이벤트'
} }
/** /**
@ -866,91 +760,6 @@ class ContentService {
return handleApiError(error) return handleApiError(error)
} }
} }
/**
* 콘텐츠 복제 (추가 기능)
* @param {number} contentId - 복제할 콘텐츠 ID
* @returns {Promise<Object>} 복제 결과
*/
async duplicateContent(contentId) {
try {
const response = await contentApi.post(`/${contentId}/duplicate`)
return formatSuccessResponse(response.data.data, '콘텐츠가 복제되었습니다.')
} catch (error) {
return handleApiError(error)
}
}
/**
* 콘텐츠 상태 변경 (추가 기능)
* @param {number} contentId - 콘텐츠 ID
* @param {string} status - 변경할 상태
* @returns {Promise<Object>} 상태 변경 결과
*/
async updateContentStatus(contentId, status) {
try {
const response = await contentApi.patch(`/${contentId}/status`, { status })
return formatSuccessResponse(response.data.data, `콘텐츠 상태가 ${status}로 변경되었습니다.`)
} catch (error) {
return handleApiError(error)
}
}
/**
* 콘텐츠 즐겨찾기 토글 (추가 기능)
* @param {number} contentId - 콘텐츠 ID
* @returns {Promise<Object>} 즐겨찾기 토글 결과
*/
async toggleContentFavorite(contentId) {
try {
const response = await contentApi.post(`/${contentId}/favorite`)
return formatSuccessResponse(response.data.data, '즐겨찾기가 변경되었습니다.')
} catch (error) {
return handleApiError(error)
}
}
/**
* 콘텐츠 템플릿 목록 조회 (추가 기능)
* @param {string} type - 템플릿 타입
* @returns {Promise<Object>} 템플릿 목록
*/
async getContentTemplates(type = 'all') {
try {
const response = await contentApi.get(`/templates?type=${type}`)
return formatSuccessResponse(response.data.data, '콘텐츠 템플릿을 조회했습니다.')
} catch (error) {
return handleApiError(error)
}
}
/**
* 템플릿으로 콘텐츠 생성 (추가 기능)
* @param {number} templateId - 템플릿 ID
* @param {Object} customData - 커스터마이징 데이터
* @returns {Promise<Object>} 생성 결과
*/
async generateFromTemplate(templateId, customData = {}) {
try {
const response = await contentApi.post(`/templates/${templateId}/generate`, customData)
return formatSuccessResponse(response.data.data, '템플릿으로 콘텐츠가 생성되었습니다.')
} catch (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)
}
}
} }
// 서비스 인스턴스 생성 및 내보내기 // 서비스 인스턴스 생성 및 내보내기

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
//* 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;">
<!-- 책자 형식 레이아웃 --> <!-- 책자 형식 레이아웃 -->
@ -230,7 +232,7 @@
multiple multiple
accept="image/*" accept="image/*"
prepend-icon="mdi-camera" prepend-icon="mdi-camera"
@change="handleFileUpload" @update:model-value="handleFileUpload"
density="compact" density="compact"
:rules="selectedType === 'poster' ? imageRequiredRules : []" :rules="selectedType === 'poster' ? imageRequiredRules : []"
/> />
@ -270,7 +272,7 @@
<v-btn <v-btn
color="primary" color="primary"
size="large" size="large"
:disabled="!formValid || remainingGenerations <= 0 || contentStore.generating" :disabled="!canGenerate || remainingGenerations <= 0 || contentStore.generating"
:loading="contentStore.generating" :loading="contentStore.generating"
@click="generateContent" @click="generateContent"
class="px-8" class="px-8"
@ -466,18 +468,54 @@
<v-divider /> <v-divider />
<v-card-text class="pa-4" style="max-height: 500px;"> <v-card-text class="pa-4" style="max-height: 500px;">
<!-- 전체 콘텐츠 --> <!-- 포스터인 경우 이미지 표시, SNS인 경우 텍스트 표시 -->
<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)"
class="pa-3 bg-grey-lighten-5 rounded html-content" <!-- 포스터인 경우 이미지로 표시 -->
style="line-height: 1.6;" <div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
v-html="currentVersion.content"> <v-img
v-if="getValidImageUrl(currentVersion.posterImage || currentVersion.content)"
:src="getValidImageUrl(currentVersion.posterImage || currentVersion.content)"
:alt="currentVersion.title"
cover
class="rounded-lg elevation-2"
style="max-width: 400px; aspect-ratio: 3/4;"
@click="previewImage(getValidImageUrl(currentVersion.posterImage || currentVersion.content), currentVersion.title)"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" />
</div>
</template>
<template v-slot:error>
<div class="d-flex flex-column align-center justify-center fill-height bg-grey-lighten-3">
<v-icon size="32" color="grey" class="mb-2">mdi-image-broken</v-icon>
<span class="text-caption text-grey">이미지를 불러올 없습니다</span>
</div>
</template>
</v-img>
<div v-else class="d-flex flex-column align-center justify-center bg-grey-lighten-4 rounded-lg pa-8">
<v-icon size="48" color="grey" class="mb-2">mdi-image-off</v-icon>
<span class="text-body-2 text-grey">포스터 이미지가 없습니다</span>
<span class="text-caption text-grey mt-1" v-if="currentVersion.posterImage || currentVersion.content">
URL: {{ currentVersion.posterImage || currentVersion.content }}
</span>
</div>
</div> </div>
<div v-else
class="text-body-2 pa-3 bg-grey-lighten-5 rounded" <!-- SNS인 경우 기존 텍스트 표시 -->
style="white-space: pre-wrap; line-height: 1.6;"> <div v-else>
{{ currentVersion.content }} <div v-if="isHtmlContent(currentVersion.content)"
class="pa-3 bg-grey-lighten-5 rounded html-content"
style="line-height: 1.6;"
v-html="currentVersion.content">
</div>
<div v-else
class="text-body-2 pa-3 bg-grey-lighten-5 rounded"
style="white-space: pre-wrap; line-height: 1.6;">
{{ currentVersion.content }}
</div>
</div> </div>
</div> </div>
@ -595,7 +633,7 @@ const selectedVersion = ref(0)
const generatedVersions = ref([]) const generatedVersions = ref([])
const remainingGenerations = ref(3) const remainingGenerations = ref(3)
// // -
const formData = ref({ const formData = ref({
title: '', title: '',
platform: '', platform: '',
@ -603,14 +641,23 @@ const formData = ref({
eventName: '', eventName: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
requirements: '', content: '',
hashtags: [],
category: '기타',
targetAge: '20대',
promotionStartDate: '', promotionStartDate: '',
promotionEndDate: '' promotionEndDate: '',
requirements: '',
}) })
// AI - // AI -
const aiOptions = ref({ const aiOptions = ref({
targetAge: '20대' toneAndManner: 'friendly',
promotion: 'general',
emotionIntensity: 'normal',
photoStyle: '밝고 화사한',
imageStyle: '모던',
targetAge: '20대',
}) })
// //
@ -639,10 +686,17 @@ const platformOptions = [
] ]
const targetTypes = [ const targetTypes = [
{ title: '신메뉴', value: 'new_menu' }, { title: '메뉴', value: 'menu' },
{ title: '할인 이벤트', value: 'discount' }, { title: '매장', value: 'store' },
{ title: '매장 홍보', value: 'store' }, { title: '이벤트', value: 'event' },
{ title: '일반 이벤트', value: 'event' } ]
//
const categoryOptions = [
{ title: '음식', value: '음식' },
{ title: '매장', value: '매장' },
{ title: '이벤트', value: '이벤트' },
{ title: '기타', value: '기타' }
] ]
// //
@ -655,6 +709,16 @@ const targetAgeOptions = [
{ title: '60대 이상', value: '60대 이상' } { title: '60대 이상', value: '60대 이상' }
] ]
const photoStyleOptions = [
{ title: '밝고 화사한', value: '밝고 화사한' },
{ title: '모던한', value: '모던' },
{ title: '미니멀한', value: '미니멀' },
{ title: '빈티지', value: '빈티지' },
{ title: '컬러풀', value: '컬러풀' },
{ title: '우아한', value: '우아한' },
{ title: '캐주얼', value: '캐주얼' }
]
// //
const getTargetTypes = (type) => { const getTargetTypes = (type) => {
if (type === 'poster') { if (type === 'poster') {
@ -713,6 +777,59 @@ const promotionEndDateRules = [
} }
] ]
// URL
const getValidImageUrl = (imageUrl) => {
if (!imageUrl || typeof imageUrl !== 'string') return null
// Azure Blob Storage URL, HTTP URL, Data URL
if (imageUrl.startsWith('http') ||
imageUrl.startsWith('data:image/') ||
imageUrl.startsWith('blob:') ||
imageUrl.startsWith('//')) {
return imageUrl
}
return null
}
//
const previewImage = (imageUrl, title) => {
if (!imageUrl) return
//
window.open(imageUrl, '_blank')
}
// : canGenerate computed
const canGenerate = computed(() => {
try {
//
if (!formValid.value) return false
if (!selectedType.value) return false
if (!formData.value.title) return false
// SNS
if (selectedType.value === 'sns' && !formData.value.platform) return false
//
if (selectedType.value === 'poster') {
if (!previewImages.value || previewImages.value.length === 0) return false
if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) return false
}
//
if (formData.value.targetType === 'event') {
if (!formData.value.eventName) return false
if (!formData.value.startDate || !formData.value.endDate) return false
}
return true
} catch (error) {
console.error('❌ canGenerate computed 에러:', error)
return false
}
})
// Computed // Computed
const currentVersion = computed(() => { const currentVersion = computed(() => {
return generatedVersions.value[selectedVersion.value] || null return generatedVersions.value[selectedVersion.value] || null
@ -724,311 +841,209 @@ const selectContentType = (type) => {
console.log(`${type} 타입 선택됨`) console.log(`${type} 타입 선택됨`)
} }
// : handleFileUpload -
const handleFileUpload = (files) => { const handleFileUpload = (files) => {
console.log('📁 파일 업로드 시작:', files) console.log('📁 파일 업로드 이벤트:', files)
// //
if (!files || files.length === 0) { if (!files || (Array.isArray(files) && files.length === 0)) {
console.log('파일이 선택되지 않음') console.log('📁 파일이 없음 - 기존 이미지 유지')
return return
} }
// FileList //
const fileArray = Array.from(files) let fileArray = []
console.log('📁 변환된 파일 배열:', fileArray) if (files instanceof FileList) {
fileArray = Array.from(files)
// } else if (Array.isArray(files)) {
fileArray = files
} else {
console.warn('⚠️ 파일 형태를 인식할 수 없음:', files)
return
}
console.log('📁 처리할 파일 개수:', fileArray.length)
// ( )
previewImages.value = [] previewImages.value = []
//
fileArray.forEach((file, index) => { fileArray.forEach((file, index) => {
if (file && file.type && file.type.startsWith('image/')) { 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({ console.log(`📁 파일 ${index + 1} 읽기 완료: ${file.name}`)
file,
url: e.target.result //
}) const existingIndex = previewImages.value.findIndex(img => img.name === file.name && img.size === file.size)
if (existingIndex === -1) {
//
previewImages.value.push({
file: file,
url: e.target.result,
name: file.name,
size: file.size
})
console.log(`✅ 파일 추가됨: ${file.name}, 현재 총 ${previewImages.value.length}`)
} else {
console.log(`⚠️ 중복 파일 무시됨: ${file.name}`)
}
} }
reader.onerror = (error) => {
console.error(`❌ 파일 ${index + 1} 읽기 실패:`, error)
}
reader.readAsDataURL(file) reader.readAsDataURL(file)
} else {
console.warn(`⚠️ 이미지가 아닌 파일 건너뜀: ${file?.name}`)
} }
}) })
} }
const removeImage = (index) => { const removeImage = (index) => {
console.log('🗑️ 이미지 삭제:', index)
previewImages.value.splice(index, 1) previewImages.value.splice(index, 1)
uploadedFiles.value.splice(index, 1)
//
if (uploadedFiles.value && uploadedFiles.value.length > index) {
const newFiles = Array.from(uploadedFiles.value)
newFiles.splice(index, 1)
uploadedFiles.value = newFiles
}
} }
// : generateContent - Java
const generateContent = async () => { const generateContent = async () => {
if (!formValid.value) { if (!canGenerate.value || remainingGenerations.value <= 0) {
appStore.showSnackbar('모든 필수 항목을 입력해주세요.', 'warning') console.log('⚠️ 생성 조건을 만족하지 않음')
return return
} }
if (remainingGenerations.value <= 0) { // 3
appStore.showSnackbar('생성 가능 횟수를 모두 사용했습니다.', 'warning') if (generatedVersions.value.length >= 3) {
appStore.showSnackbar('최대 3개의 버전까지만 생성할 수 있습니다.', 'warning')
return return
} }
try { try {
console.log(`🎯 [UI] ${selectedType.value.toUpperCase()} 콘텐츠 생성 요청 시작`) console.log('🎯 콘텐츠 생성 시작')
// //
console.log('📁 [UI] 현재 이미지 상태:') let contentData
console.log(' - previewImages.value:', previewImages.value)
console.log(' - previewImages 타입:', typeof previewImages.value)
console.log(' - previewImages 배열 여부:', Array.isArray(previewImages.value))
console.log(' - previewImages 길이:', previewImages.value?.length)
if (previewImages.value && Array.isArray(previewImages.value)) {
previewImages.value.forEach((img, index) => {
console.log(` - 이미지 ${index + 1}:`, {
exists: !!img,
hasUrl: !!img?.url,
urlType: typeof img?.url,
urlLength: img?.url?.length,
urlPreview: img?.url?.substring(0, 50)
})
})
}
// 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') { if (selectedType.value === 'poster') {
console.log('🎯 [UI] 포스터 생성 데이터 구성') // Java PosterContentCreateRequest
console.log('📁 [UI] 포스터용 이미지 체크:', { contentData = {
processedImagesCount: processedImages.length, type: selectedType.value,
previewImagesCount: previewImages.value?.length || 0, contentType: selectedType.value,
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') // Java (PosterContentCreateRequest )
return storeId: 1,
title: formData.value.title,
targetAudience: convertTargetAudienceToKorean(formData.value.targetType),
promotionStartDate: formData.value.promotionStartDate,
promotionEndDate: formData.value.promotionEndDate,
images: previewImages.value.map(img => img.url),
// (Java DTO )
menuName: formData.value.targetType === 'menu' ? formData.value.title : null,
eventName: formData.value.targetType === 'event' ? formData.value.eventName : null,
imageStyle: aiOptions.value.imageStyle || '모던',
category: getJavaCategory(formData.value.targetType),
requirement: formData.value.requirements || `${formData.value.title}에 대한 포스터를 만들어주세요`,
startDate: convertDateTimeToDateStrict(formData.value.startDate),
endDate: convertDateTimeToDateStrict(formData.value.endDate),
photoStyle: aiOptions.value.photoStyle || '밝고 화사한'
} }
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 { } else {
console.log('🎯 [UI] SNS 생성 데이터 구성') // Java SnsContentCreateRequest
contentData = {
if (!formData.value.platform) { type: selectedType.value,
appStore.showSnackbar('플랫폼을 선택해주세요.', 'warning') contentType: selectedType.value,
return
// Java (SnsContentCreateRequest )
storeId: 1,
storeName: '샘플 매장',
storeType: '음식점',
platform: formData.value.platform,
title: formData.value.title,
category: getJavaCategory(formData.value.targetType),
requirement: formData.value.requirements || `${formData.value.title}에 대한 SNS 게시물을 만들어주세요`,
target: convertTargetAudienceToKorean(formData.value.targetType),
images: previewImages.value.map(img => img.url),
//
eventName: formData.value.targetType === 'event' ? formData.value.eventName : null,
startDate: convertDateTimeToDateStrict(formData.value.startDate),
endDate: convertDateTimeToDateStrict(formData.value.endDate)
} }
contentData.platform = formData.value.platform
contentData.targetType = formData.value.targetType || 'new_menu'
contentData.category = getCategory(formData.value.targetType)
} }
// // undefined (Java )
console.log('📤 [UI] 최종 contentData:', { Object.keys(contentData).forEach(key => {
type: contentData.type, if (contentData[key] === undefined) {
title: contentData.title, delete contentData[key]
platform: contentData.platform, }
category: contentData.category,
storeId: contentData.storeId,
imageCount: contentData.images.length,
firstImagePreview: contentData.images[0]?.substring(0, 50)
}) })
// console.log('🎯 [GENERATE] Java 백엔드용 데이터:', contentData)
//
if (!contentData.title) { if (!contentData.title) {
throw new Error('제목을 입력해주세요.') throw new Error('제목은 필수입니다.')
} }
if (selectedType.value === 'sns' && !contentData.platform) { if (selectedType.value === 'poster') {
throw new Error('플랫폼을 선택해주세요.') if (!contentData.targetAudience) {
} throw new Error('홍보 대상은 필수입니다.')
}
if (selectedType.value === 'poster' && contentData.images.length === 0) { if (!contentData.promotionStartDate || !contentData.promotionEndDate) {
throw new Error('포스터 생성을 위해서는 최소 1개의 이미지가 필요합니다.') throw new Error('홍보 기간은 필수입니다.')
} }
if (!contentData.images || contentData.images.length === 0) {
// contentData throw new Error('포스터 생성을 위해서는 이미지가 필요합니다.')
if (!contentData || typeof contentData !== 'object') { }
throw new Error('콘텐츠 데이터 구성에 실패했습니다.') } else {
} if (!contentData.platform) {
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)
if (!generated || !generated.success) {
throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.')
}
let finalContent = generated.content || generated.data?.content || ''
// 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;">
<img src="${imageUrl}" style="width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);" />
</div>`
).join('')
if (isHtmlContent(finalContent)) {
finalContent = imageHtml + finalContent
} else {
finalContent = imageHtml + `<div style="padding: 15px; font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6;">${finalContent.replace(/\n/g, '<br>')}</div>`
} }
} }
const newContent = {
id: Date.now() + Math.random(),
...contentData,
content: finalContent,
hashtags: generated.hashtags || generated.data?.hashtags || [],
createdAt: new Date(),
status: 'draft',
uploadedImages: previewImages.value || [],
platform: contentData.platform || 'POSTER'
}
generatedVersions.value.push(newContent) // AI - store.generateContent
selectedVersion.value = generatedVersions.value.length - 1 const generated = await contentStore.generateContent(contentData)
remainingGenerations.value--
appStore.showSnackbar(`콘텐츠 버전 ${generatedVersions.value.length}이 생성되었습니다!`, 'success') console.log('🎯 [GENERATE] AI 생성 응답:', generated)
if (generated && generated.success) {
const newContent = {
id: Date.now() + Math.random(),
...contentData,
//
targetType: formData.value.targetType,
platform: selectedType.value === 'sns' ? formData.value.platform : 'poster',
content: generated.content || generated.data?.content || '생성된 콘텐츠 내용',
hashtags: generated.hashtags || generated.data?.hashtags || [],
createdAt: new Date(),
status: 'draft',
// posterImage
posterImage: selectedType.value === 'poster' ? (generated.posterImage || generated.data?.posterImage || generated.content) : null
}
generatedVersions.value.push(newContent)
selectedVersion.value = generatedVersions.value.length - 1
remainingGenerations.value--
console.log('✅ [GENERATE] AI 콘텐츠 생성 성공:', newContent)
appStore.showSnackbar(`콘텐츠 버전 ${generatedVersions.value.length}이 생성되었습니다!`, 'success')
} else {
throw new Error(generated?.error || '콘텐츠 생성에 실패했습니다.')
}
} catch (error) { } catch (error) {
console.error('❌ [UI] 콘텐츠 생성 실패:', error) console.error('❌ [GENERATE] 콘텐츠 생성 실패:', error)
console.error('❌ [UI] 에러 스택:', error.stack) appStore.showSnackbar(`콘텐츠 생성 중 오류가 발생했습니다: ${error.message}`, 'error')
appStore.showSnackbar(error.message || '콘텐츠 생성 중 오류가 발생했습니다.', 'error')
} }
} }
@ -1053,7 +1068,10 @@ const saveVersion = async (index) => {
try { try {
const version = generatedVersions.value[index] const version = generatedVersions.value[index]
await contentStore.saveContent({ // contentStore.saveContent
const saveData = {
type: version.type || version.contentType,
contentType: version.contentType || version.type,
title: version.title, title: version.title,
content: version.content, content: version.content,
hashtags: version.hashtags, hashtags: version.hashtags,
@ -1061,19 +1079,26 @@ const saveVersion = async (index) => {
category: getCategory(version.targetType), category: getCategory(version.targetType),
eventName: version.eventName, eventName: version.eventName,
eventDate: version.eventDate, eventDate: version.eventDate,
status: 'PUBLISHED' status: 'PUBLISHED',
}) storeId: version.storeId
}
version.status = 'published' const result = await contentStore.saveContent(saveData)
version.publishedAt = new Date()
appStore.showSnackbar(`버전 ${index + 1}이 성공적으로 저장되었습니다!`, 'success') if (result.success) {
version.status = 'published'
setTimeout(() => { version.publishedAt = new Date()
if (confirm('저장된 콘텐츠를 확인하시겠습니까?')) {
router.push('/content') appStore.showSnackbar(`버전 ${index + 1}이 성공적으로 저장되었습니다!`, 'success')
}
}, 1000) setTimeout(() => {
if (confirm('저장된 콘텐츠를 확인하시겠습니까?')) {
router.push('/content')
}
}, 1000)
} else {
throw new Error(result.error || '저장에 실패했습니다.')
}
} catch (error) { } catch (error) {
console.error('❌ 콘텐츠 저장 실패:', error) console.error('❌ 콘텐츠 저장 실패:', error)
appStore.showSnackbar(error.message || '콘텐츠 저장 중 오류가 발생했습니다.', 'error') appStore.showSnackbar(error.message || '콘텐츠 저장 중 오류가 발생했습니다.', 'error')
@ -1160,6 +1185,85 @@ const getPlatformLabel = (platform) => {
return labels[platform] || platform return labels[platform] || platform
} }
// Java
const convertTargetAudienceToKorean = (targetType) => {
const mapping = {
'menu': '메뉴',
'store': '매장',
'event': '이벤트',
'service': '서비스',
'discount': '할인혜택'
}
return mapping[targetType] || '기타'
}
// Java ( )
const getJavaCategory = (targetType) => {
const mapping = {
'menu': '메뉴소개',
'store': '매장홍보',
'event': '이벤트',
'service': '서비스',
'discount': '이벤트'
}
return mapping[targetType] || '이벤트'
}
const convertCategoryToKorean = (category) => {
const mapping = {
'음식': '이벤트',
'매장': '이벤트',
'이벤트': '이벤트',
'기타': '이벤트'
}
return mapping[category] || '이벤트'
}
// YYYY-MM-DD
const convertDateTimeToDateStrict = (dateTimeString) => {
if (!dateTimeString) return undefined // null undefined
try {
let dateStr = dateTimeString
// "2025-06-19T09:58" -> "2025-06-19"
if (dateTimeString.includes('T')) {
dateStr = dateTimeString.split('T')[0]
}
// YYYY-MM-DD
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
if (!dateRegex.test(dateStr)) {
console.warn('⚠️ 잘못된 날짜 형식:', dateTimeString)
return undefined
}
//
const date = new Date(dateStr)
if (isNaN(date.getTime())) {
console.warn('⚠️ 유효하지 않은 날짜:', dateStr)
return undefined
}
return dateStr
} catch (error) {
console.error('❌ 날짜 변환 오류:', error, dateTimeString)
return undefined
}
}
const convertDateTimeToDate = (dateTimeString) => {
if (!dateTimeString) return null
// "2025-06-19T09:58" -> "2025-06-19"
if (dateTimeString.includes('T')) {
return dateTimeString.split('T')[0]
}
// YYYY-MM-DD
return dateTimeString
}
const getStatusColor = (status) => { const getStatusColor = (status) => {
const colors = { const colors = {
'draft': 'grey', 'draft': 'grey',