release
This commit is contained in:
parent
d85bbf903d
commit
68f84ab357
@ -28,43 +28,58 @@
|
||||
<div v-else class="poster-result">
|
||||
<!-- 포스터 이미지 -->
|
||||
<div class="poster-image-container mb-4">
|
||||
<!-- ✅ 이미지 URL 유효성 검사 후 렌더링 -->
|
||||
<v-img
|
||||
:src="posterData.posterImage || '/images/placeholder-poster.jpg'"
|
||||
v-if="getPosterImageUrl()"
|
||||
:src="getPosterImageUrl()"
|
||||
:alt="posterData.title"
|
||||
cover
|
||||
class="rounded-lg elevation-4"
|
||||
style="aspect-ratio: 3/4; max-height: 400px;"
|
||||
class="rounded-lg elevation-4 poster-image"
|
||||
style="aspect-ratio: 3/4; max-height: 400px; width: 100%;"
|
||||
>
|
||||
<template v-slot:placeholder>
|
||||
<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>
|
||||
</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 class="d-flex flex-column align-center justify-center fill-height bg-grey-lighten-3">
|
||||
<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>
|
||||
</template>
|
||||
</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
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-download"
|
||||
@click="downloadPoster"
|
||||
class="mr-2"
|
||||
:disabled="!getPosterImageUrl()"
|
||||
>
|
||||
다운로드
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-share-variant"
|
||||
@click="sharePoster"
|
||||
:disabled="!getPosterImageUrl()"
|
||||
>
|
||||
공유
|
||||
</v-btn>
|
||||
@ -72,97 +87,95 @@
|
||||
</div>
|
||||
|
||||
<!-- 포스터 정보 -->
|
||||
<v-card variant="outlined" class="mb-4">
|
||||
<v-card class="mb-4" variant="outlined">
|
||||
<v-card-title class="text-h6">
|
||||
{{ posterData.title }}
|
||||
{{ 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="poster-details">
|
||||
<div v-if="posterData.targetAudience" class="detail-item mb-2">
|
||||
<v-icon class="mr-2" size="small">mdi-target</v-icon>
|
||||
<span class="text-body-2">
|
||||
<strong>홍보 대상:</strong> {{ posterData.targetAudience }}
|
||||
</span>
|
||||
</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="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.imageStyle" class="text-caption text-grey-600">
|
||||
스타일: {{ posterData.imageStyle }}
|
||||
<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 v-if="posterData.status" class="detail-item">
|
||||
<v-chip
|
||||
:color="getStatusColor(posterData.status)"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
{{ getStatusText(posterData.status) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</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-card-title class="text-h6">
|
||||
<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-btn
|
||||
v-for="(url, size) in posterData.posterSizes"
|
||||
:key="size"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="viewPosterSize(size, url)"
|
||||
class="cursor-pointer"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ size }}
|
||||
</v-chip>
|
||||
</v-btn>
|
||||
</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-dialog v-model="showSizeDialog" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
포스터 사이즈: {{ selectedSize }}
|
||||
{{ selectedSize }} 포스터
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="text-center">
|
||||
<v-card-text>
|
||||
<v-img
|
||||
:src="selectedSizeUrl"
|
||||
:alt="`포스터 ${selectedSize}`"
|
||||
contain
|
||||
max-height="400"
|
||||
cover
|
||||
class="rounded-lg"
|
||||
style="max-height: 500px;"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
@ -185,7 +198,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 포스터 미리보기 컴포넌트
|
||||
@ -211,6 +224,38 @@ const showSizeDialog = ref(false)
|
||||
const selectedSize = 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 = () => {
|
||||
if (!props.posterData?.posterImage) return
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) 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)
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
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 () => {
|
||||
if (!props.posterData?.posterImage) return
|
||||
const imageUrl = getPosterImageUrl()
|
||||
if (!imageUrl) {
|
||||
console.error('❌ [PosterPreview] 공유할 이미지 URL이 없습니다')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔗 [PosterPreview] 포스터 공유 시도:', imageUrl)
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: props.posterData.title,
|
||||
title: props.posterData.title || '생성된 포스터',
|
||||
text: '생성된 홍보 포스터를 확인해보세요!',
|
||||
url: props.posterData.posterImage
|
||||
url: imageUrl
|
||||
})
|
||||
console.log('✅ [PosterPreview] 공유 완료')
|
||||
} catch (error) {
|
||||
console.log('공유 취소됨')
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('❌ [PosterPreview] 공유 실패:', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 클립보드에 URL 복사
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.posterData.posterImage)
|
||||
// 성공 알림 표시 (부모 컴포넌트에서 처리)
|
||||
await navigator.clipboard.writeText(imageUrl)
|
||||
console.log('✅ [PosterPreview] 클립보드 복사 완료')
|
||||
// 성공 알림은 부모 컴포넌트에서 처리
|
||||
} catch (error) {
|
||||
console.error('클립보드 복사 실패:', error)
|
||||
console.error('❌ [PosterPreview] 클립보드 복사 실패:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -289,9 +375,12 @@ const viewPosterSize = (size, url) => {
|
||||
* 선택된 사이즈 포스터 다운로드
|
||||
*/
|
||||
const downloadSelectedSize = () => {
|
||||
if (!selectedSizeUrl.value) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = selectedSizeUrl.value
|
||||
link.download = `${props.posterData.title || '포스터'}_${selectedSize.value}.jpg`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
@ -307,12 +396,50 @@ const downloadSelectedSize = () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.poster-image {
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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>
|
||||
@ -1,4 +1,5 @@
|
||||
//* src/services/content.js - 완전한 파일 (모든 수정사항 포함)
|
||||
//* src/services/content.js - 수정된 완전한 파일
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
// runtime-env.js에서 API URL 가져오기 (대체 방식 포함)
|
||||
@ -100,7 +101,7 @@ const handleApiError = (error) => {
|
||||
|
||||
/**
|
||||
* 콘텐츠 서비스 클래스 - 완전 통합 버전
|
||||
* 백엔드 API 설계서와 일치하도록 구현
|
||||
* Java 백엔드 multipart/form-data API와 연동
|
||||
*/
|
||||
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 - 콘텐츠 생성 데이터
|
||||
* @returns {Promise<Object>} 생성된 콘텐츠
|
||||
*/
|
||||
@ -185,146 +225,92 @@ class ContentService {
|
||||
try {
|
||||
console.log('🤖 SNS 콘텐츠 생성 요청:', contentData)
|
||||
|
||||
// ✅ contentData 기본 검증
|
||||
if (!contentData || typeof contentData !== 'object') {
|
||||
throw new Error('콘텐츠 데이터가 전달되지 않았습니다.')
|
||||
// ✅ Java 백엔드 필수 필드 검증 (SnsContentCreateRequest 기준)
|
||||
if (!contentData.storeId) {
|
||||
throw new Error('매장 ID는 필수입니다.')
|
||||
}
|
||||
|
||||
// ✅ images 속성 보장 (방어 코드)
|
||||
if (!contentData.hasOwnProperty('images')) {
|
||||
console.warn('⚠️ [API] images 속성이 없음, 빈 배열로 설정')
|
||||
contentData.images = []
|
||||
if (!contentData.platform) {
|
||||
throw new Error('플랫폼은 필수입니다.')
|
||||
}
|
||||
|
||||
if (!Array.isArray(contentData.images)) {
|
||||
console.warn('⚠️ [API] images가 배열이 아님, 빈 배열로 변환:', typeof contentData.images)
|
||||
contentData.images = []
|
||||
if (!contentData.title) {
|
||||
throw new Error('콘텐츠 제목은 필수입니다.')
|
||||
}
|
||||
|
||||
// ✅ 필수 필드 검증
|
||||
const requiredFields = ['title', 'platform']
|
||||
const missingFields = requiredFields.filter(field => !contentData[field])
|
||||
// ✅ FormData 생성 (multipart/form-data)
|
||||
const formData = new FormData()
|
||||
|
||||
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] || '기타'
|
||||
}
|
||||
|
||||
// ✅ 요청 데이터 구성
|
||||
// ✅ request JSON 부분 구성 (Java SnsContentCreateRequest DTO에 맞춤)
|
||||
const requestData = {
|
||||
// 필수 필드들
|
||||
title: contentData.title.trim(),
|
||||
platform: normalizeplatform(contentData.platform),
|
||||
storeId: contentData.storeId || 1,
|
||||
storeName: contentData.storeName || '샘플 매장',
|
||||
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',
|
||||
category: contentData.category || getCategoryFromTargetType(contentData.targetType),
|
||||
images: contentData.images || [] // 기본값 보장
|
||||
eventName: contentData.eventName || null,
|
||||
startDate: this.convertToJavaDate(contentData.startDate),
|
||||
endDate: this.convertToJavaDate(contentData.endDate)
|
||||
}
|
||||
|
||||
// ✅ 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
|
||||
// null 값 제거
|
||||
Object.keys(requestData).forEach(key => {
|
||||
if (requestData[key] === null || requestData[key] === undefined) {
|
||||
delete requestData[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ✅ 선택적 필드들
|
||||
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
|
||||
console.log('📝 [API] Java 백엔드용 SNS 요청 데이터:', requestData)
|
||||
|
||||
// ✅ 이미지 처리 (contentData.images가 보장됨)
|
||||
console.log('📁 [API] 이미지 처리 시작:', contentData.images.length, '개')
|
||||
// ✅ request를 JSON 문자열로 FormData에 추가
|
||||
formData.append('request', JSON.stringify(requestData))
|
||||
|
||||
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
|
||||
// ✅ 이미지 파일들을 FormData에 추가 (SNS는 선택적)
|
||||
if (contentData.images && contentData.images.length > 0) {
|
||||
// Base64 이미지를 Blob으로 변환하여 추가
|
||||
for (let i = 0; i < contentData.images.length; i++) {
|
||||
const imageData = contentData.images[i]
|
||||
if (typeof imageData === 'string' && imageData.startsWith('data:image/')) {
|
||||
try {
|
||||
const blob = this.base64ToBlob(imageData)
|
||||
formData.append('files', blob, `image_${i}.jpg`)
|
||||
} 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, {
|
||||
timeout: 30000
|
||||
console.log('📁 [API] FormData 구성 완료')
|
||||
|
||||
// ✅ 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)
|
||||
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) {
|
||||
console.error('❌ [API] SNS 콘텐츠 생성 실패:', error)
|
||||
@ -349,215 +335,96 @@ class ContentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 포스터 생성 (CON-020: AI 포스터 생성) - 수정된 버전
|
||||
* ✅ multipart/form-data 형식으로 수정된 포스터 생성
|
||||
* @param {Object} posterData - 포스터 생성 데이터
|
||||
* @returns {Promise<Object>} 생성된 포스터
|
||||
* @returns {Promise<Object>} 생성 결과
|
||||
*/
|
||||
async generatePoster(posterData) {
|
||||
try {
|
||||
console.log('🎯 [API] 포스터 생성 요청 받음:', posterData)
|
||||
|
||||
// ✅ 1. 이미지 상세 분석 및 검증
|
||||
console.log('📁 [API] 이미지 상세 분석 시작...')
|
||||
console.log('📁 [API] posterData.images 타입:', typeof posterData.images)
|
||||
console.log('📁 [API] posterData.images 배열 여부:', Array.isArray(posterData.images))
|
||||
console.log('📁 [API] posterData.images 길이:', posterData.images?.length)
|
||||
// ✅ Java 백엔드 필수 필드 검증 (PosterContentCreateRequest 기준)
|
||||
if (!posterData.title) {
|
||||
throw new Error('제목은 필수입니다.')
|
||||
}
|
||||
|
||||
let processedImages = []
|
||||
if (!posterData.targetAudience && !posterData.targetType) {
|
||||
throw new Error('홍보 대상은 필수입니다.')
|
||||
}
|
||||
|
||||
if (posterData.images && Array.isArray(posterData.images) && posterData.images.length > 0) {
|
||||
console.log('📁 [API] 원본 이미지 배열 처리 시작...')
|
||||
if (!posterData.promotionStartDate) {
|
||||
throw new Error('홍보 시작일은 필수입니다.')
|
||||
}
|
||||
|
||||
// 각 이미지를 개별적으로 검증
|
||||
posterData.images.forEach((img, index) => {
|
||||
console.log(`📁 [API] 이미지 ${index + 1} 분석:`, {
|
||||
type: typeof img,
|
||||
isString: typeof img === 'string',
|
||||
length: img?.length,
|
||||
isNull: img === null,
|
||||
isUndefined: img === undefined,
|
||||
isEmpty: img === '',
|
||||
isBase64: typeof img === 'string' && img.startsWith('data:image/'),
|
||||
preview: typeof img === 'string' ? img.substring(0, 50) + '...' : 'Not string'
|
||||
})
|
||||
})
|
||||
if (!posterData.promotionEndDate) {
|
||||
throw new Error('홍보 종료일은 필수입니다.')
|
||||
}
|
||||
|
||||
// 유효한 이미지만 필터링 (더 엄격한 검증)
|
||||
processedImages = posterData.images.filter((img, index) => {
|
||||
const isValid = img &&
|
||||
typeof img === 'string' &&
|
||||
img.length > 100 && // 최소 길이 체크 (Base64는 보통 매우 길다)
|
||||
(img.startsWith('data:image/') || img.startsWith('http'))
|
||||
// ✅ FormData 생성 (multipart/form-data)
|
||||
const formData = new FormData()
|
||||
|
||||
console.log(`📁 [API] 이미지 ${index + 1} 유효성:`, {
|
||||
isValid,
|
||||
reason: !img ? 'null/undefined' :
|
||||
typeof img !== 'string' ? 'not string' :
|
||||
img.length <= 100 ? 'too short' :
|
||||
!img.startsWith('data:image/') && !img.startsWith('http') ? 'invalid format' :
|
||||
'valid'
|
||||
})
|
||||
// ✅ request JSON 부분 구성 (Java PosterContentCreateRequest DTO에 맞춤)
|
||||
const requestData = {
|
||||
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),
|
||||
menuName: posterData.menuName || (posterData.targetType === 'menu' ? posterData.title : null),
|
||||
eventName: posterData.eventName || null,
|
||||
imageStyle: posterData.imageStyle || '모던',
|
||||
category: posterData.category || '이벤트',
|
||||
requirement: posterData.requirement || posterData.requirements || `${posterData.title}에 대한 포스터를 만들어주세요`,
|
||||
startDate: this.convertToJavaDate(posterData.startDate),
|
||||
endDate: this.convertToJavaDate(posterData.endDate),
|
||||
photoStyle: posterData.photoStyle || '밝고 화사한'
|
||||
}
|
||||
|
||||
return isValid
|
||||
})
|
||||
|
||||
console.log('📁 [API] 필터링 결과:', {
|
||||
원본개수: posterData.images.length,
|
||||
유효개수: processedImages.length,
|
||||
제거된개수: posterData.images.length - processedImages.length
|
||||
})
|
||||
|
||||
if (processedImages.length === 0) {
|
||||
console.error('❌ [API] 유효한 이미지가 없습니다!')
|
||||
console.error('❌ [API] 원본 이미지 상태:', posterData.images.map((img, i) => ({
|
||||
index: i,
|
||||
type: typeof img,
|
||||
length: img?.length,
|
||||
preview: typeof img === 'string' ? img.substring(0, 30) : 'not string'
|
||||
})))
|
||||
|
||||
throw new Error('유효한 이미지가 없습니다. 이미지를 다시 선택해 주세요.')
|
||||
// null 값 제거
|
||||
Object.keys(requestData).forEach(key => {
|
||||
if (requestData[key] === null || requestData[key] === undefined) {
|
||||
delete requestData[key]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('📝 [API] Java 백엔드용 요청 데이터:', requestData)
|
||||
|
||||
// ✅ request를 JSON 문자열로 FormData에 추가
|
||||
formData.append('request', JSON.stringify(requestData))
|
||||
|
||||
// ✅ 이미지 파일들을 FormData에 추가
|
||||
if (posterData.images && posterData.images.length > 0) {
|
||||
// Base64 이미지를 Blob으로 변환하여 추가
|
||||
for (let i = 0; i < posterData.images.length; i++) {
|
||||
const imageData = posterData.images[i]
|
||||
if (typeof imageData === 'string' && imageData.startsWith('data:image/')) {
|
||||
try {
|
||||
const blob = this.base64ToBlob(imageData)
|
||||
formData.append('images', blob, `image_${i}.jpg`)
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ 이미지 ${i} 변환 실패:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ [API] 이미지가 없거나 유효하지 않음!')
|
||||
console.warn('⚠️ [API] posterData.images:', posterData.images)
|
||||
processedImages = []
|
||||
}
|
||||
|
||||
// ✅ 2. 필수 필드 검증 강화
|
||||
const validationErrors = []
|
||||
console.log('📁 [API] FormData 구성 완료')
|
||||
|
||||
if (!posterData.title || posterData.title.trim() === '') {
|
||||
validationErrors.push('제목은 필수입니다.')
|
||||
}
|
||||
|
||||
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초로 증가 (포스터 생성은 시간이 걸림)
|
||||
// ✅ multipart/form-data로 Java 백엔드 API 호출
|
||||
const response = await contentApi.post('/poster/generate', formData, {
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ [API] 포스터 생성 응답 수신:', {
|
||||
status: response.status,
|
||||
hasData: !!response.data,
|
||||
dataType: typeof response.data
|
||||
})
|
||||
console.log('✅ [API] 응답 데이터:', response.data)
|
||||
console.log('✅ [API] 포스터 생성 응답:', response.data)
|
||||
|
||||
// ✅ 6. 백엔드 응답 구조에 맞춰 처리
|
||||
if (response.data && response.data.success !== false) {
|
||||
return formatSuccessResponse(response.data, '홍보 포스터가 생성되었습니다.')
|
||||
// ✅ Java 백엔드 ApiResponse 구조에 맞춰 처리
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
return formatSuccessResponse(response.data.data, '홍보 포스터가 생성되었습니다.')
|
||||
} else if (response.data && response.data.status === 200 && response.data.data) {
|
||||
return formatSuccessResponse(response.data.data, '홍보 포스터가 생성되었습니다.')
|
||||
} else {
|
||||
throw new Error(response.data?.message || '포스터 생성에 실패했습니다.')
|
||||
}
|
||||
@ -565,57 +432,26 @@ class ContentService {
|
||||
} catch (error) {
|
||||
console.error('❌ [API] 포스터 생성 실패:', error)
|
||||
|
||||
// ✅ 7. 백엔드 오류 상세 정보 추출 및 분석
|
||||
if (error.response) {
|
||||
console.error('❌ [API] HTTP 응답 오류:')
|
||||
console.error(' - Status:', error.response.status)
|
||||
console.error(' - Status Text:', error.response.statusText)
|
||||
console.error(' - Headers:', error.response.headers)
|
||||
console.error(' - Data:', JSON.stringify(error.response.data, null, 2))
|
||||
console.error(' - Data:', error.response.data)
|
||||
|
||||
// 백엔드에서 반환하는 구체적인 오류 메시지 추출
|
||||
let backendMessage = '서버 오류가 발생했습니다.'
|
||||
let errorMessage = '서버 오류가 발생했습니다.'
|
||||
|
||||
if (error.response.data) {
|
||||
if (typeof error.response.data === 'string') {
|
||||
backendMessage = error.response.data
|
||||
} else if (error.response.data.message) {
|
||||
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 = '서버에서 포스터 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
if (error.response.data?.message) {
|
||||
errorMessage = error.response.data.message
|
||||
} else if (error.response.data?.error) {
|
||||
errorMessage = error.response.data.error
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: backendMessage,
|
||||
message: errorMessage,
|
||||
error: error.response.data,
|
||||
statusCode: error.response.status
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('❌ [API] 네트워크 요청 오류:', error.request)
|
||||
return {
|
||||
success: false,
|
||||
message: '서버에 연결할 수 없습니다. 네트워크 연결을 확인해 주세요.',
|
||||
error: 'NETWORK_ERROR'
|
||||
}
|
||||
} else {
|
||||
console.error('❌ [API] 일반 오류:', error.message)
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '포스터 생성 중 예상치 못한 오류가 발생했습니다.',
|
||||
@ -626,36 +462,83 @@ class ContentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 콘텐츠 생성 (타입에 따라 SNS 또는 포스터 생성) - 수정된 버전
|
||||
* @param {Object} contentData - 콘텐츠 생성 데이터
|
||||
* @returns {Promise<Object>} 생성 결과
|
||||
* ✅ Base64 이미지를 Blob으로 변환
|
||||
* @param {string} base64Data - Base64 이미지 데이터
|
||||
* @returns {Blob} 변환된 Blob 객체
|
||||
*/
|
||||
async generateContent(contentData) {
|
||||
console.log('🎯 [API] 통합 콘텐츠 생성:', contentData)
|
||||
base64ToBlob(base64Data) {
|
||||
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 유효성 검사 추가
|
||||
if (!contentData || typeof contentData !== 'object') {
|
||||
console.error('❌ [API] contentData가 유효하지 않음:', contentData)
|
||||
return {
|
||||
success: false,
|
||||
message: '콘텐츠 데이터가 유효하지 않습니다.',
|
||||
error: 'INVALID_CONTENT_DATA'
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n)
|
||||
}
|
||||
|
||||
return new Blob([u8arr], { type: mime })
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 날짜를 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
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ images 속성 보장
|
||||
if (!Array.isArray(contentData.images)) {
|
||||
console.warn('⚠️ [API] images 속성이 배열이 아님, 빈 배열로 초기화:', contentData.images)
|
||||
contentData.images = []
|
||||
// "2025-06-19" 형식이면 시간 추가
|
||||
return dateTimeString + 'T00:00:00'
|
||||
} catch (error) {
|
||||
console.error('❌ DateTime 변환 오류:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (contentData.contentType === 'poster' || contentData.type === 'poster') {
|
||||
return await this.generatePoster(contentData)
|
||||
} else {
|
||||
return await this.generateSnsContent(contentData)
|
||||
/**
|
||||
* ✅ 날짜를 Java LocalDate 형식으로 변환
|
||||
* @param {string} dateString - 날짜 문자열
|
||||
* @returns {string} Java LocalDate 형식 (yyyy-MM-dd)
|
||||
*/
|
||||
convertToJavaDate(dateString) {
|
||||
if (!dateString) return null
|
||||
|
||||
try {
|
||||
// "2025-06-19T09:58" -> "2025-06-19"
|
||||
if (dateString.includes('T')) {
|
||||
return dateString.split('T')[0]
|
||||
}
|
||||
|
||||
// 이미 yyyy-MM-dd 형식이면 그대로 반환
|
||||
return dateString
|
||||
} catch (error) {
|
||||
console.error('❌ Date 변환 오류:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 플랫폼 이름 정규화
|
||||
* @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()
|
||||
}
|
||||
|
||||
/**
|
||||
* SNS 콘텐츠 저장 (CON-010: SNS 게시물 저장)
|
||||
* @param {Object} saveData - 저장할 콘텐츠 데이터
|
||||
@ -731,16 +614,15 @@ class ContentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 콘텐츠 조회
|
||||
* @param {string} period - 조회 기간
|
||||
* @returns {Promise<Object>} 진행 중인 콘텐츠 목록
|
||||
* 콘텐츠 저장 (통합)
|
||||
* @param {Object} saveData - 저장할 콘텐츠 데이터
|
||||
* @returns {Promise<Object>} 저장 결과
|
||||
*/
|
||||
async getOngoingContents(period = 'month') {
|
||||
try {
|
||||
const response = await contentApi.get(`/ongoing?period=${period}`)
|
||||
return formatSuccessResponse(response.data.data, '진행 중인 콘텐츠를 조회했습니다.')
|
||||
} catch (error) {
|
||||
return handleApiError(error)
|
||||
async saveContent(saveData) {
|
||||
if (saveData.contentType === 'poster' || saveData.type === 'poster') {
|
||||
return await this.savePoster(saveData)
|
||||
} else {
|
||||
return await this.saveSnsContent(saveData)
|
||||
}
|
||||
}
|
||||
|
||||
@ -803,20 +685,32 @@ class ContentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 타겟 타입을 카테고리로 매핑
|
||||
* @param {string} targetType - 타겟 타입
|
||||
* @returns {string} 매핑된 카테고리
|
||||
* 콘텐츠 상태 변경 (추가 기능)
|
||||
* @param {number} contentId - 콘텐츠 ID
|
||||
* @param {string} status - 변경할 상태
|
||||
* @returns {Promise<Object>} 상태 변경 결과
|
||||
*/
|
||||
mapTargetToCategory(targetType) {
|
||||
const mapping = {
|
||||
'new_menu': '메뉴소개',
|
||||
'discount': '이벤트',
|
||||
'store': '인테리어',
|
||||
'event': '이벤트',
|
||||
'menu': '메뉴소개',
|
||||
'service': '서비스'
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠 복제 (추가 기능)
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 인스턴스 생성 및 내보내기
|
||||
|
||||
1118
src/store/content.js
1118
src/store/content.js
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
||||
//* src/views/ContentCreationView.vue - 수정된 완전한 파일
|
||||
|
||||
<template>
|
||||
<v-container fluid class="pa-0" style="height: 100vh; overflow: hidden;">
|
||||
<!-- 책자 형식 레이아웃 -->
|
||||
@ -230,7 +232,7 @@
|
||||
multiple
|
||||
accept="image/*"
|
||||
prepend-icon="mdi-camera"
|
||||
@change="handleFileUpload"
|
||||
@update:model-value="handleFileUpload"
|
||||
density="compact"
|
||||
:rules="selectedType === 'poster' ? imageRequiredRules : []"
|
||||
/>
|
||||
@ -270,7 +272,7 @@
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
:disabled="!formValid || remainingGenerations <= 0 || contentStore.generating"
|
||||
:disabled="!canGenerate || remainingGenerations <= 0 || contentStore.generating"
|
||||
:loading="contentStore.generating"
|
||||
@click="generateContent"
|
||||
class="px-8"
|
||||
@ -466,18 +468,54 @@
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-4" style="max-height: 500px;">
|
||||
<!-- 전체 콘텐츠 -->
|
||||
<!-- ✅ 포스터인 경우 이미지 표시, SNS인 경우 텍스트 표시 -->
|
||||
<div class="mb-4">
|
||||
<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;"
|
||||
v-html="currentVersion.content">
|
||||
|
||||
<!-- ✅ 포스터인 경우 이미지로 표시 -->
|
||||
<div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
|
||||
<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 v-else
|
||||
class="text-body-2 pa-3 bg-grey-lighten-5 rounded"
|
||||
style="white-space: pre-wrap; line-height: 1.6;">
|
||||
{{ currentVersion.content }}
|
||||
|
||||
<!-- ✅ SNS인 경우 기존 텍스트 표시 -->
|
||||
<div v-else>
|
||||
<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>
|
||||
|
||||
@ -595,7 +633,7 @@ const selectedVersion = ref(0)
|
||||
const generatedVersions = ref([])
|
||||
const remainingGenerations = ref(3)
|
||||
|
||||
// 폼 데이터
|
||||
// 폼 데이터 - 누락된 필드들 추가
|
||||
const formData = ref({
|
||||
title: '',
|
||||
platform: '',
|
||||
@ -603,14 +641,23 @@ const formData = ref({
|
||||
eventName: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
requirements: '',
|
||||
content: '',
|
||||
hashtags: [],
|
||||
category: '기타',
|
||||
targetAge: '20대',
|
||||
promotionStartDate: '',
|
||||
promotionEndDate: ''
|
||||
promotionEndDate: '',
|
||||
requirements: '',
|
||||
})
|
||||
|
||||
// AI 옵션 - 타겟 연령층만
|
||||
// AI 옵션 - 누락된 필드들 추가
|
||||
const aiOptions = ref({
|
||||
targetAge: '20대'
|
||||
toneAndManner: 'friendly',
|
||||
promotion: 'general',
|
||||
emotionIntensity: 'normal',
|
||||
photoStyle: '밝고 화사한',
|
||||
imageStyle: '모던',
|
||||
targetAge: '20대',
|
||||
})
|
||||
|
||||
// 상수 정의
|
||||
@ -639,10 +686,17 @@ const platformOptions = [
|
||||
]
|
||||
|
||||
const targetTypes = [
|
||||
{ title: '신메뉴', value: 'new_menu' },
|
||||
{ title: '할인 이벤트', value: 'discount' },
|
||||
{ title: '매장 홍보', value: 'store' },
|
||||
{ title: '일반 이벤트', value: 'event' }
|
||||
{ title: '메뉴', value: 'menu' },
|
||||
{ title: '매장', value: 'store' },
|
||||
{ title: '이벤트', value: 'event' },
|
||||
]
|
||||
|
||||
// 추가 옵션들 정의
|
||||
const categoryOptions = [
|
||||
{ title: '음식', value: '음식' },
|
||||
{ title: '매장', value: '매장' },
|
||||
{ title: '이벤트', value: '이벤트' },
|
||||
{ title: '기타', value: '기타' }
|
||||
]
|
||||
|
||||
// 타겟 연령층 옵션
|
||||
@ -655,6 +709,16 @@ const targetAgeOptions = [
|
||||
{ title: '60대 이상', value: '60대 이상' }
|
||||
]
|
||||
|
||||
const photoStyleOptions = [
|
||||
{ title: '밝고 화사한', value: '밝고 화사한' },
|
||||
{ title: '모던한', value: '모던' },
|
||||
{ title: '미니멀한', value: '미니멀' },
|
||||
{ title: '빈티지', value: '빈티지' },
|
||||
{ title: '컬러풀', value: '컬러풀' },
|
||||
{ title: '우아한', value: '우아한' },
|
||||
{ title: '캐주얼', value: '캐주얼' }
|
||||
]
|
||||
|
||||
// 타입별 타겟 옵션 함수
|
||||
const getTargetTypes = (type) => {
|
||||
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
|
||||
const currentVersion = computed(() => {
|
||||
return generatedVersions.value[selectedVersion.value] || null
|
||||
@ -724,311 +841,209 @@ const selectContentType = (type) => {
|
||||
console.log(`${type} 타입 선택됨`)
|
||||
}
|
||||
|
||||
// 수정: handleFileUpload 함수 - 중복 등록 방지
|
||||
const handleFileUpload = (files) => {
|
||||
console.log('📁 파일 업로드 시작:', files)
|
||||
console.log('📁 파일 업로드 이벤트:', files)
|
||||
|
||||
// 파일이 없는 경우 처리
|
||||
if (!files || files.length === 0) {
|
||||
console.log('파일이 선택되지 않음')
|
||||
if (!files || (Array.isArray(files) && files.length === 0)) {
|
||||
console.log('📁 파일이 없음 - 기존 이미지 유지')
|
||||
return
|
||||
}
|
||||
|
||||
// FileList를 배열로 변환
|
||||
const fileArray = Array.from(files)
|
||||
console.log('📁 변환된 파일 배열:', fileArray)
|
||||
// 파일 배열로 변환
|
||||
let 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 = []
|
||||
|
||||
// 각 파일 개별 처리
|
||||
fileArray.forEach((file, index) => {
|
||||
if (file && file.type && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
previewImages.value.push({
|
||||
file,
|
||||
url: e.target.result
|
||||
})
|
||||
console.log(`📁 파일 ${index + 1} 읽기 완료: ${file.name}`)
|
||||
|
||||
// 중복 방지를 위해 기존에 같은 이름의 파일이 있는지 확인
|
||||
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)
|
||||
} else {
|
||||
console.warn(`⚠️ 이미지가 아닌 파일 건너뜀: ${file?.name}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeImage = (index) => {
|
||||
console.log('🗑️ 이미지 삭제:', index)
|
||||
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 () => {
|
||||
if (!formValid.value) {
|
||||
appStore.showSnackbar('모든 필수 항목을 입력해주세요.', 'warning')
|
||||
if (!canGenerate.value || remainingGenerations.value <= 0) {
|
||||
console.log('⚠️ 생성 조건을 만족하지 않음')
|
||||
return
|
||||
}
|
||||
|
||||
if (remainingGenerations.value <= 0) {
|
||||
appStore.showSnackbar('생성 가능 횟수를 모두 사용했습니다.', 'warning')
|
||||
// 최대 3개 버전 체크
|
||||
if (generatedVersions.value.length >= 3) {
|
||||
appStore.showSnackbar('최대 3개의 버전까지만 생성할 수 있습니다.', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🎯 [UI] ${selectedType.value.toUpperCase()} 콘텐츠 생성 요청 시작`)
|
||||
console.log('🎯 콘텐츠 생성 시작')
|
||||
|
||||
// ✅ 이미지 상태 상세 로깅
|
||||
console.log('📁 [UI] 현재 이미지 상태:')
|
||||
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)
|
||||
// ✅ 콘텐츠 타입에 따른 데이터 구성 분기
|
||||
let contentData
|
||||
|
||||
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') {
|
||||
console.log('🎯 [UI] 포스터 생성 데이터 구성')
|
||||
console.log('📁 [UI] 포스터용 이미지 체크:', {
|
||||
processedImagesCount: processedImages.length,
|
||||
previewImagesCount: previewImages.value?.length || 0,
|
||||
hasImages: processedImages.length > 0
|
||||
})
|
||||
// ✅ Java 백엔드 PosterContentCreateRequest에 맞게 데이터 구성
|
||||
contentData = {
|
||||
type: selectedType.value,
|
||||
contentType: selectedType.value,
|
||||
|
||||
// ✅ 포스터 이미지 체크 (상세 로깅)
|
||||
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
|
||||
})
|
||||
// ✅ Java 백엔드 필수 필드들 (PosterContentCreateRequest 기준)
|
||||
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),
|
||||
|
||||
appStore.showSnackbar('포스터 생성을 위해 최소 1개의 이미지를 업로드해 주세요.', 'warning')
|
||||
return
|
||||
// ✅ 선택적 필드들 (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 {
|
||||
console.log('🎯 [UI] SNS 생성 데이터 구성')
|
||||
// ✅ Java 백엔드 SnsContentCreateRequest에 맞게 데이터 구성
|
||||
contentData = {
|
||||
type: selectedType.value,
|
||||
contentType: selectedType.value,
|
||||
|
||||
if (!formData.value.platform) {
|
||||
appStore.showSnackbar('플랫폼을 선택해주세요.', 'warning')
|
||||
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)
|
||||
}
|
||||
|
||||
// ✅ 최종 검증 및 로깅
|
||||
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)
|
||||
// ✅ undefined 값들 제거 (Java에서 오류 방지)
|
||||
Object.keys(contentData).forEach(key => {
|
||||
if (contentData[key] === undefined) {
|
||||
delete contentData[key]
|
||||
}
|
||||
})
|
||||
|
||||
// ✅ 필수 필드 최종 체크
|
||||
console.log('🎯 [GENERATE] Java 백엔드용 데이터:', contentData)
|
||||
|
||||
// ✅ 필수 필드 재검증
|
||||
if (!contentData.title) {
|
||||
throw new Error('제목을 입력해주세요.')
|
||||
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)
|
||||
|
||||
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>`
|
||||
if (selectedType.value === 'poster') {
|
||||
if (!contentData.targetAudience) {
|
||||
throw new Error('홍보 대상은 필수입니다.')
|
||||
}
|
||||
if (!contentData.promotionStartDate || !contentData.promotionEndDate) {
|
||||
throw new Error('홍보 기간은 필수입니다.')
|
||||
}
|
||||
if (!contentData.images || contentData.images.length === 0) {
|
||||
throw new Error('포스터 생성을 위해서는 이미지가 필요합니다.')
|
||||
}
|
||||
} else {
|
||||
if (!contentData.platform) {
|
||||
throw new Error('플랫폼은 필수입니다.')
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
// AI 콘텐츠 생성 - store.generateContent에 단일 파라미터로 전달
|
||||
const generated = await contentStore.generateContent(contentData)
|
||||
|
||||
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 || '콘텐츠 생성에 실패했습니다.')
|
||||
}
|
||||
|
||||
generatedVersions.value.push(newContent)
|
||||
selectedVersion.value = generatedVersions.value.length - 1
|
||||
remainingGenerations.value--
|
||||
|
||||
appStore.showSnackbar(`콘텐츠 버전 ${generatedVersions.value.length}이 생성되었습니다!`, 'success')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [UI] 콘텐츠 생성 실패:', error)
|
||||
console.error('❌ [UI] 에러 스택:', error.stack)
|
||||
appStore.showSnackbar(error.message || '콘텐츠 생성 중 오류가 발생했습니다.', 'error')
|
||||
console.error('❌ [GENERATE] 콘텐츠 생성 실패:', error)
|
||||
appStore.showSnackbar(`콘텐츠 생성 중 오류가 발생했습니다: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@ -1053,7 +1068,10 @@ const saveVersion = async (index) => {
|
||||
try {
|
||||
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,
|
||||
content: version.content,
|
||||
hashtags: version.hashtags,
|
||||
@ -1061,19 +1079,26 @@ const saveVersion = async (index) => {
|
||||
category: getCategory(version.targetType),
|
||||
eventName: version.eventName,
|
||||
eventDate: version.eventDate,
|
||||
status: 'PUBLISHED'
|
||||
})
|
||||
status: 'PUBLISHED',
|
||||
storeId: version.storeId
|
||||
}
|
||||
|
||||
version.status = 'published'
|
||||
version.publishedAt = new Date()
|
||||
const result = await contentStore.saveContent(saveData)
|
||||
|
||||
appStore.showSnackbar(`버전 ${index + 1}이 성공적으로 저장되었습니다!`, 'success')
|
||||
if (result.success) {
|
||||
version.status = 'published'
|
||||
version.publishedAt = new Date()
|
||||
|
||||
setTimeout(() => {
|
||||
if (confirm('저장된 콘텐츠를 확인하시겠습니까?')) {
|
||||
router.push('/content')
|
||||
}
|
||||
}, 1000)
|
||||
appStore.showSnackbar(`버전 ${index + 1}이 성공적으로 저장되었습니다!`, 'success')
|
||||
|
||||
setTimeout(() => {
|
||||
if (confirm('저장된 콘텐츠를 확인하시겠습니까?')) {
|
||||
router.push('/content')
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
throw new Error(result.error || '저장에 실패했습니다.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 콘텐츠 저장 실패:', error)
|
||||
appStore.showSnackbar(error.message || '콘텐츠 저장 중 오류가 발생했습니다.', 'error')
|
||||
@ -1160,6 +1185,85 @@ const getPlatformLabel = (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 colors = {
|
||||
'draft': 'grey',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user