This commit is contained in:
OhSeongRak 2025-06-19 16:35:47 +09:00
commit 8ff40e3beb
7 changed files with 1629 additions and 1343 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>
<span class="text-body-2">
<strong>홍보 대상:</strong> {{ posterData.targetAudience }}
</span>
</div> </div>
<div class="d-flex align-center mb-2"> <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 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
color="primary"
size="small"
variant="outlined"
>
{{ posterData.contentType }}
</v-chip>
</div> </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>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
//* src/store/content.js - 두 파일 완전 통합 버전 (Part 1) //* src/store/content.js - 콘텐츠 관리 기능이 통합된 최종 버전
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue' import { ref, computed, readonly } from 'vue'
import contentService from '@/services/content' import contentService from '@/services/content'
@ -203,32 +203,91 @@ export const useContentStore = defineStore('content', () => {
} }
} }
/** // ===== 기존 API 호출 함수들을 통합된 방식으로 수정 =====
* 콘텐츠 목록 조회 (기존 호환성 유지)
*/
const fetchContentList = async (requestFilters = {}) => {
console.log('📋 [STORE] fetchContentList 호출:', requestFilters)
return await loadContents(requestFilters)
}
// ===== AI 콘텐츠 생성 =====
/** /**
* SNS 콘텐츠 생성 (API 설계서 기준) - 이미지 디버깅 강화 * generateContent를 실제 API 호출로 수정 - 단일 파라미터로 변경하고 contentService.generateContent 사용
*/ */
const generateSnsContent = async (contentData) => { const generateContent = async (contentData) => {
generating.value = true generating.value = true
try { try {
console.log('🎯 [STORE] SNS 콘텐츠 생성 요청:', contentData) console.log('🎯 [STORE] generateContent 호출됨:', contentData)
console.log('📁 [STORE] SNS 이미지 확인:', {
hasImages: !!contentData.images, // contentService의 통합 generateContent 함수 사용
imageCount: contentData.images?.length || 0, const result = await contentService.generateContent(contentData)
imageTypes: contentData.images?.map(img => typeof img) || [],
imageSizes: contentData.images?.map(img => img?.length || 'unknown') || [] console.log('🎯 [STORE] API 응답:', result)
})
if (result && result.success) {
generatedContent.value = result.data
return {
success: true,
data: result.data,
content: result.data?.content || result.data?.text,
hashtags: result.data?.hashtags || []
}
} else {
return {
success: false,
error: result?.message || result?.error || 'API 응답이 올바르지 않습니다.'
}
}
} catch (error) {
console.error('❌ [STORE] generateContent 실패:', error)
return {
success: false,
error: error.message || '네트워크 오류가 발생했습니다.'
}
} finally {
generating.value = false
}
}
/**
* saveContent를 실제 API 호출로 수정 - 단일 파라미터로 변경
*/
const saveContent = async (contentData) => {
isLoading.value = true
try {
console.log('💾 [STORE] saveContent 호출됨:', contentData)
// contentService의 통합 saveContent 함수 사용
const result = await contentService.saveContent(contentData)
if (result && result.success) {
// 콘텐츠 목록 새로고침
await fetchContentList()
return { success: true, message: '콘텐츠가 저장되었습니다.' }
} else {
return {
success: false,
error: result?.message || result?.error || '저장에 실패했습니다.'
}
}
} catch (error) {
console.error('❌ [STORE] saveContent 실패:', error)
return {
success: false,
error: error.message || '네트워크 오류가 발생했습니다.'
}
} finally {
isLoading.value = false
}
}
/**
* 포스터 저장 - 수정된 버전
*/
const savePoster = async (saveData) => {
loading.value = true
try {
console.log('💾 [STORE] 포스터 저장 요청:', saveData)
// 매장 ID 조회 (필요한 경우) // 매장 ID 조회 (필요한 경우)
let storeId = contentData.storeId let storeId = saveData.storeId
if (!storeId) { if (!storeId) {
try { try {
storeId = await getStoreId() storeId = await getStoreId()
@ -238,282 +297,64 @@ export const useContentStore = defineStore('content', () => {
} }
} }
// SnsContentCreateRequest 구조에 맞게 데이터 변환 // ✅ PosterContentSaveRequest 구조에 맞게 데이터 변환 (contentId 처리 개선)
const requestData = { const requestData = {
// ✅ contentId 처리: 값이 있으면 사용, 없으면 임시 ID 생성
contentId: saveData.contentId || Date.now(),
storeId: storeId, storeId: storeId,
storeName: contentData.storeName || '', title: saveData.title || '',
storeType: contentData.storeType || '',
platform: contentData.platform || 'INSTAGRAM', // ✅ content 필드에 실제 값 보장 (null이면 안됨)
title: contentData.title || 'SNS 게시물', content: saveData.content || saveData.title || '포스터 콘텐츠',
category: contentData.category || '메뉴소개',
requirement: contentData.requirement || contentData.requirements || 'SNS 게시물을 생성해주세요', // ✅ images 배열이 비어있지 않도록 보장
target: contentData.target || contentData.targetAudience || '', images: Array.isArray(saveData.images) ? saveData.images.filter(img => img) : [],
contentType: contentData.contentType || 'SNS 게시물',
eventName: contentData.eventName || '', status: saveData.status || 'PUBLISHED',
startDate: contentData.startDate, category: saveData.category || '이벤트',
endDate: contentData.endDate, requirement: saveData.requirement || '',
images: contentData.images || [], toneAndManner: saveData.toneAndManner || '친근함',
photoStyle: contentData.photoStyle || '밝고 화사한', emotionIntensity: saveData.emotionIntensity || '보통',
targetAge: contentData.targetAge || '20대', eventName: saveData.eventName || '',
toneAndManner: contentData.toneAndManner || '친근함', startDate: saveData.startDate,
emotionalIntensity: contentData.emotionalIntensity || contentData.emotionIntensity || '보통', endDate: saveData.endDate,
promotionalType: contentData.promotionalType || contentData.promotionType || '', photoStyle: saveData.photoStyle || '밝고 화사한'
eventDate: contentData.eventDate,
hashtagStyle: contentData.hashtagStyle || '',
hashtagCount: contentData.hashtagCount || 10,
contentLength: contentData.contentLength || '보통',
includeHashtags: contentData.includeHashtags !== false,
includeEmojis: contentData.includeEmojis !== false,
includeEmoji: contentData.includeEmoji !== false,
includeCallToAction: contentData.includeCallToAction !== false,
includeLocation: contentData.includeLocation || false,
forInstagramStory: contentData.forInstagramStory || false,
forNaverBlogPost: contentData.forNaverBlogPost || false,
alternativeTitleCount: contentData.alternativeTitleCount || 3,
alternativeHashtagSetCount: contentData.alternativeHashtagSetCount || 2,
preferredAiModel: contentData.preferredAiModel || ''
} }
console.log('📤 [STORE] SNS 변환된 요청 데이터:', { // ✅ 필수 필드 검증
if (!requestData.title) {
throw new Error('제목은 필수입니다.')
}
if (!requestData.images || requestData.images.length === 0) {
throw new Error('이미지는 필수입니다.')
}
console.log('📝 [STORE] 최종 저장 요청 데이터:', {
...requestData, ...requestData,
images: `${requestData.images.length}개 이미지` images: `${requestData.images.length}개 이미지`
}) })
const result = await contentService.generateSnsContent(requestData) const result = await contentService.savePoster(requestData)
if (result.success) { if (result.success) {
console.log('✅ [STORE] SNS 콘텐츠 생성 성공:', result.data) console.log('✅ [STORE] 포스터 저장 성공')
generatedContent.value = result.data
return { // 목록 새로고침
success: true, await loadContents()
content: result.data.content || '콘텐츠가 생성되었습니다.',
hashtags: result.data.hashtags || [], return { success: true, message: '포스터가 저장되었습니다.' }
data: result.data
}
} else { } else {
console.error('❌ [STORE] SNS 콘텐츠 생성 실패:', result.message) console.error('❌ [STORE] 포스터 저장 실패:', result.message)
return { return { success: false, error: result.message }
success: false,
message: result.message || 'SNS 콘텐츠 생성에 실패했습니다.',
error: result.error
}
}
} catch (error) {
console.error('❌ [STORE] SNS 콘텐츠 생성 예외:', error)
return {
success: false,
message: error.message || '네트워크 오류가 발생했습니다.',
error: error
}
} finally {
generating.value = false
}
}
//* src/store/content.js - 두 파일 완전 통합 버전 (Part 2)
/**
* 포스터 생성 (API 설계서 기준) - 이미지 디버깅 대폭 강화
*/
const generatePoster = async (posterData) => {
generating.value = true
try {
console.log('🎯 [STORE] 포스터 생성 요청 받음:', posterData)
console.log('📁 [STORE] 포스터 이미지 상세 분석:', {
hasImages: !!posterData.images,
imageCount: posterData.images?.length || 0,
imageArray: Array.isArray(posterData.images),
firstImageInfo: posterData.images?.[0] ? {
type: typeof posterData.images[0],
length: posterData.images[0]?.length || 'unknown',
isBase64: posterData.images[0]?.startsWith?.('data:image/') || false,
preview: posterData.images[0]?.substring(0, 50) + '...'
} : null
})
// 매장 ID 조회 (필요한 경우)
let storeId = posterData.storeId
if (storeId === undefined || storeId === null) {
try {
storeId = await getStoreId()
} catch (error) {
console.warn('⚠️ 매장 ID 조회 실패, 기본값 사용:', error)
storeId = 1
}
}
// ✅ 실제 전달받은 데이터만 사용 (기본값 완전 제거)
const requestData = {}
// 조건부로 필드 추가 (값이 있을 때만)
if (storeId !== undefined) {
requestData.storeId = storeId
console.log('📝 [STORE] storeId 추가:', requestData.storeId)
}
if (posterData.title) {
requestData.title = posterData.title
console.log('📝 [STORE] title 추가:', requestData.title)
}
if (posterData.targetAudience) {
requestData.targetAudience = posterData.targetAudience
console.log('📝 [STORE] targetAudience 추가:', requestData.targetAudience)
} else if (posterData.targetType) {
requestData.targetAudience = posterData.targetType
console.log('📝 [STORE] targetAudience 추가 (from targetType):', requestData.targetAudience)
}
if (posterData.promotionStartDate) {
requestData.promotionStartDate = posterData.promotionStartDate
console.log('📝 [STORE] promotionStartDate 추가:', requestData.promotionStartDate)
}
if (posterData.promotionEndDate) {
requestData.promotionEndDate = posterData.promotionEndDate
console.log('📝 [STORE] promotionEndDate 추가:', requestData.promotionEndDate)
}
// 선택적 필드들
if (posterData.eventName) {
requestData.eventName = posterData.eventName
console.log('📝 [STORE] eventName 추가:', requestData.eventName)
}
if (posterData.imageStyle) requestData.imageStyle = posterData.imageStyle
if (posterData.promotionType) requestData.promotionType = posterData.promotionType
if (posterData.emotionIntensity) requestData.emotionIntensity = posterData.emotionIntensity
if (posterData.category) requestData.category = posterData.category
if (posterData.requirement || 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('📝 [STORE] targetAge 추가:', requestData.targetAge)
}
// ✅ 이미지 처리 - 가장 중요한 부분
console.log('📁 [STORE] 이미지 처리 시작...')
if (posterData.images && Array.isArray(posterData.images) && posterData.images.length > 0) {
console.log('📁 [STORE] 원본 이미지 배열:', posterData.images.length, '개')
// 유효한 이미지만 필터링
const validImages = posterData.images.filter(img => {
const isValid = img && typeof img === 'string' && img.length > 0
console.log('📁 [STORE] 이미지 유효성 검사:', {
isValid,
type: typeof img,
length: img?.length,
isBase64: img?.startsWith?.('data:image/')
})
return isValid
})
requestData.images = validImages
console.log('📁 [STORE] 필터링된 이미지:', validImages.length, '개')
console.log('📁 [STORE] 첫 번째 이미지 샘플:', validImages[0]?.substring(0, 100) + '...')
} else {
requestData.images = []
console.warn('📁 [STORE] ⚠️ 이미지가 없거나 유효하지 않음!')
console.warn('📁 [STORE] posterData.images:', posterData.images)
}
console.log('🔍 [STORE] 최종 요청 데이터 확인:')
console.log(' - 제목:', requestData.title)
console.log(' - 홍보 대상:', requestData.targetAudience)
console.log(' - 타겟 연령층:', requestData.targetAge)
console.log(' - 홍보 시작일:', requestData.promotionStartDate)
console.log(' - 홍보 종료일:', requestData.promotionEndDate)
console.log(' - 이미지 개수:', requestData.images.length)
if (requestData.images.length === 0) {
console.error('❌ [STORE] 포스터에 이미지가 없습니다!')
return {
success: false,
message: '포스터 생성을 위해서는 최소 1개의 이미지가 필요합니다.'
}
}
const result = await contentService.generatePoster(requestData)
if (result.success) {
console.log('✅ [STORE] 포스터 생성 성공:', result.data)
generatedContent.value = result.data
return {
success: true,
content: result.data.posterImage || result.data.content || '포스터가 생성되었습니다.',
posterImage: result.data.posterImage,
title: result.data.title,
data: result.data
}
} else {
console.error('❌ [STORE] 포스터 생성 실패:', result.message)
return {
success: false,
message: result.message || '포스터 생성에 실패했습니다.',
error: result.error
}
}
} catch (error) {
console.error('❌ [STORE] 포스터 생성 예외:', error)
return {
success: false,
message: error.message || '네트워크 오류가 발생했습니다.',
error: error
}
} finally {
generating.value = false
}
}
/**
* AI 콘텐츠 생성 통합 메서드 (기존 호환성 유지)
*/
const generateContent = async (type, formData) => {
console.log('🎯 [STORE] 콘텐츠 생성 요청 (통합):', { type, formData })
console.log('📁 [STORE] 통합 메서드 이미지 확인:', {
hasImages: !!formData.images,
imageCount: formData.images?.length || 0
})
isLoading.value = true
try {
let result
// 타입에 따라 적절한 메서드 호출
if (type === 'poster' || formData.contentType === 'poster' || formData.type === 'poster') {
console.log('🎯 [STORE] 포스터 생성으로 라우팅')
result = await generatePoster(formData)
} else if (type === 'sns' || type === 'snsContent') {
console.log('🎯 [STORE] SNS 생성으로 라우팅')
result = await generateSnsContent(formData)
} else {
console.log('🎯 [STORE] 기본 SNS 생성으로 라우팅')
result = await generateSnsContent(formData)
}
if (result.success) {
return { success: true, data: result.data || result }
} else {
return { success: false, error: result.message || result.error }
} }
} catch (error) { } catch (error) {
console.error('❌ [STORE] 통합 콘텐츠 생성 실패:', error) console.error('❌ [STORE] 포스터 저장 예외:', error)
return { success: false, error: '네트워크 오류가 발생했습니다.' } return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally { } finally {
isLoading.value = false loading.value = false
} }
} }
// ===== 콘텐츠 저장 =====
/** /**
* SNS 콘텐츠 저장 * SNS 콘텐츠 저장
*/ */
@ -536,25 +377,23 @@ export const useContentStore = defineStore('content', () => {
// SnsContentSaveRequest 구조에 맞게 데이터 변환 // SnsContentSaveRequest 구조에 맞게 데이터 변환
const requestData = { const requestData = {
contentId: saveData.contentId, contentId: saveData.contentId || Date.now(), // 임시 ID 생성
storeId: storeId, storeId: storeId,
platform: saveData.platform || 'INSTAGRAM', platform: saveData.platform || 'INSTAGRAM',
title: saveData.title || '', title: saveData.title || '',
content: saveData.content || '', content: saveData.content || '',
hashtags: saveData.hashtags || [], hashtags: saveData.hashtags || [],
images: saveData.images || [], images: saveData.images || [],
finalTitle: saveData.finalTitle || saveData.title || '', finalTitle: saveData.finalTitle || saveData.title,
finalContent: saveData.finalContent || saveData.content || '', finalContent: saveData.finalContent || saveData.content,
status: saveData.status || 'DRAFT', status: saveData.status || 'DRAFT',
category: saveData.category || '메뉴소개', category: saveData.category || '메뉴소개',
requirement: saveData.requirement || '', requirement: saveData.requirement || '',
toneAndManner: saveData.toneAndManner || '친근함', toneAndManner: saveData.toneAndManner || '친근함',
emotionIntensity: saveData.emotionIntensity || saveData.emotionalIntensity || '보통', emotionIntensity: saveData.emotionIntensity || '보통',
eventName: saveData.eventName || '', eventName: saveData.eventName || '',
startDate: saveData.startDate, startDate: saveData.startDate,
endDate: saveData.endDate, endDate: saveData.endDate
promotionalType: saveData.promotionalType,
eventDate: saveData.eventDate
} }
const result = await contentService.saveSnsContent(requestData) const result = await contentService.saveSnsContent(requestData)
@ -579,127 +418,39 @@ export const useContentStore = defineStore('content', () => {
} }
/** /**
* 포스터 저장 * fetchContentList를 실제 API 호출로 수정 (기존 호환성 유지)
*/ */
const savePoster = async (saveData) => { const fetchContentList = async (requestFilters = {}) => {
loading.value = true console.log('📋 [STORE] fetchContentList 호출:', requestFilters)
return await loadContents(requestFilters)
try {
console.log('💾 [STORE] 포스터 저장 요청:', saveData)
// 매장 ID 조회 (필요한 경우)
let storeId = saveData.storeId
if (!storeId) {
try {
storeId = await getStoreId()
} catch (error) {
console.warn('⚠️ 매장 ID 조회 실패, 기본값 사용:', error)
storeId = 1
}
}
// PosterContentSaveRequest 구조에 맞게 데이터 변환
const requestData = {
contentId: saveData.contentId,
storeId: storeId,
title: saveData.title || '',
content: saveData.content || '',
images: saveData.images || [],
status: saveData.status || 'DRAFT',
category: saveData.category || '이벤트',
requirement: saveData.requirement || '',
toneAndManner: saveData.toneAndManner || '친근함',
emotionIntensity: saveData.emotionIntensity || '보통',
eventName: saveData.eventName || '',
startDate: saveData.startDate,
endDate: saveData.endDate,
photoStyle: saveData.photoStyle || '밝고 화사한',
targetAudience: saveData.targetAudience,
promotionType: saveData.promotionType,
imageStyle: saveData.imageStyle,
promotionStartDate: saveData.promotionStartDate,
promotionEndDate: saveData.promotionEndDate
}
const result = await contentService.savePoster(requestData)
if (result.success) {
console.log('✅ [STORE] 포스터 저장 성공')
// 목록 새로고침
await loadContents()
return { success: true, message: '포스터가 저장되었습니다.' }
} else {
console.error('❌ [STORE] 포스터 저장 실패:', result.message)
return { success: false, error: result.message }
}
} catch (error) {
console.error('❌ [STORE] 포스터 저장 예외:', error)
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
loading.value = false
}
} }
/** /**
* 콘텐츠 저장 통합 메서드 (기존 호환성 유지) * fetchOngoingContents를 실제 API 호출로 수정
*/
const saveContent = async (type, contentData) => {
console.log('💾 [STORE] 콘텐츠 저장 요청 (통합):', { type, contentData })
isLoading.value = true
try {
let result
// 타입에 따라 적절한 메서드 호출
if (type === 'poster' || contentData.contentType === 'poster' || contentData.type === 'poster') {
result = await savePoster(contentData)
} else if (type === 'sns' || type === 'snsContent') {
result = await saveSnsContent(contentData)
} else {
// 기본적으로 SNS 콘텐츠로 간주
result = await saveSnsContent(contentData)
}
if (result.success) {
return { success: true, message: result.message || '콘텐츠가 저장되었습니다.' }
} else {
return { success: false, error: result.error }
}
} catch (error) {
console.error('❌ [STORE] 통합 콘텐츠 저장 실패:', error)
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
// ===== 기타 콘텐츠 관리 메서드들 =====
/**
* 진행 중인 콘텐츠 조회
*/ */
const fetchOngoingContents = async (period = 'month') => { const fetchOngoingContents = async (period = 'month') => {
isLoading.value = true isLoading.value = true
loading.value = true
try { try {
const result = await contentService.getOngoingContents(period) const result = await contentService.getOngoingContents(period)
if (result.success) { if (result && result.success) {
ongoingContents.value = result.data || [] ongoingContents.value = result.data || []
return { success: true } return { success: true }
} else { } else {
console.error('❌ 진행 중인 콘텐츠 조회 실패:', result.message) return {
return { success: false, error: result.message } success: false,
error: result?.message || result?.error || '진행 중인 콘텐츠 조회에 실패했습니다.'
}
} }
} catch (error) { } catch (error) {
console.error('❌ 진행 중인 콘텐츠 조회 예외:', error) console.error('❌ [STORE] fetchOngoingContents 실패:', error)
return { success: false, error: '네트워크 오류가 발생했습니다.' } return {
success: false,
error: error.message || '네트워크 오류가 발생했습니다.'
}
} finally { } finally {
isLoading.value = false isLoading.value = false
loading.value = false
} }
} }
@ -707,25 +458,33 @@ export const useContentStore = defineStore('content', () => {
* 콘텐츠 상세 조회 * 콘텐츠 상세 조회
*/ */
const fetchContentDetail = async (contentId) => { const fetchContentDetail = async (contentId) => {
loading.value = true isLoading.value = true
try { try {
const result = await contentService.getContentDetail(contentId) const result = await contentService.getContentDetail(contentId)
if (result.success) { if (result && result.success) {
selectedContent.value = result.data selectedContent.value = result.data
return result.data return { success: true, data: result.data }
} else { } else {
throw new Error(result.message || '콘텐츠 상세 조회에 실패했습니다.') return {
success: false,
error: result?.message || result?.error || '콘텐츠 상세 조회에 실패했습니다.'
}
} }
} catch (error) { } catch (error) {
console.error('❌ 콘텐츠 상세 조회 실패:', error) console.error('❌ [STORE] fetchContentDetail 실패:', error)
throw error return {
success: false,
error: error.message || '네트워크 오류가 발생했습니다.'
}
} finally { } finally {
loading.value = false isLoading.value = false
} }
} }
// ===== 콘텐츠 관리 기능들 =====
/** /**
* 콘텐츠 수정 * 콘텐츠 수정
*/ */
@ -781,80 +540,66 @@ export const useContentStore = defineStore('content', () => {
} }
} }
// ===== 유틸리티 메서드들 =====
/** /**
* 타겟 타입을 카테고리로 매핑 * 콘텐츠 발행
*/ */
const mapTargetToCategory = (targetType) => { const publishContent = async (contentId, publishData) => {
const mapping = { isLoading.value = true
'new_menu': '메뉴소개',
'discount': '이벤트',
'store': '인테리어',
'event': '이벤트',
'menu': '메뉴소개',
'service': '서비스'
}
return mapping[targetType] || '메뉴소개'
}
/** try {
* 플랫폼별 특성 조회 const result = await contentService.publishContent(contentId, publishData)
*/
const getPlatformSpec = (platform) => { if (result && result.success) {
return PLATFORM_SPECS?.[platform] || null // 목록 새로고침
await fetchContentList()
return { success: true, message: '콘텐츠가 발행되었습니다.' }
} else {
return {
success: false,
error: result?.message || result?.error || '발행에 실패했습니다.'
} }
/**
* 플랫폼 유효성 검사
*/
const validatePlatform = (platform) => {
return PLATFORM_SPECS ? Object.keys(PLATFORM_SPECS).includes(platform) : true
} }
} catch (error) {
/** console.error('❌ [STORE] publishContent 실패:', error)
* 필터 설정 return {
*/ success: false,
const setFilters = (newFilters) => { error: error.message || '네트워크 오류가 발생했습니다.'
filters.value = { ...filters.value, ...newFilters }
pagination.value.page = 1 // 필터 변경 시 첫 페이지로
} }
} finally {
/**
* 페이지네이션 설정
*/
const setPagination = (newPagination) => {
pagination.value = { ...pagination.value, ...newPagination }
}
/**
* 상태 초기화
*/
const reset = () => {
contentList.value = []
contents.value = []
ongoingContents.value = []
selectedContent.value = null
generatedContent.value = null
totalCount.value = 0
filters.value = {
contentType: '',
platform: '',
period: '',
sortBy: 'latest'
}
pagination.value = {
page: 1,
itemsPerPage: 10
}
isLoading.value = false isLoading.value = false
loading.value = false }
generating.value = false
} }
// ===== 고급 기능들 (추가) ===== /**
* 콘텐츠 통계 조회
*/
const fetchContentStats = async (options = {}) => {
isLoading.value = true
try {
const result = await contentService.getContentStats(options)
if (result && result.success) {
return { success: true, data: result.data }
} else {
return {
success: false,
error: result?.message || result?.error || '통계 조회에 실패했습니다.'
}
}
} catch (error) {
console.error('❌ [STORE] fetchContentStats 실패:', error)
return {
success: false,
error: error.message || '네트워크 오류가 발생했습니다.'
}
} finally {
isLoading.value = false
}
}
// ===== 추가된 고급 콘텐츠 관리 기능들 =====
/** /**
* 콘텐츠 검색 * 콘텐츠 검색
*/ */
@ -881,7 +626,7 @@ export const useContentStore = defineStore('content', () => {
} }
/** /**
* 콘텐츠 통계 조회 * 콘텐츠 통계 조회 (추가)
*/ */
const getContentStats = async (statsFilters = {}) => { const getContentStats = async (statsFilters = {}) => {
loading.value = true loading.value = true
@ -971,54 +716,81 @@ export const useContentStore = defineStore('content', () => {
} }
} }
// ===== 유틸리티 메서드들 =====
/** /**
* 콘텐츠 템플릿 목록 조회 * 타겟 타입을 카테고리로 매핑
*/ */
const getContentTemplates = async (type = 'all') => { const mapTargetToCategory = (targetType) => {
loading.value = true const mapping = {
'new_menu': '메뉴소개',
try { 'discount': '이벤트',
const result = await contentService.getContentTemplates(type) 'store': '인테리어',
'event': '이벤트',
if (result.success) { 'menu': '메뉴소개',
return { success: true, data: result.data } 'service': '서비스'
} else {
return { success: false, error: result.message }
} }
} catch (error) { return mapping[targetType] || '메뉴소개'
console.error('❌ 템플릿 목록 조회 실패:', error) }
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally { /**
* 플랫폼별 특성 조회
*/
const getPlatformSpec = (platform) => {
return PLATFORM_SPECS?.[platform] || null
}
/**
* 플랫폼 유효성 검사
*/
const validatePlatform = (platform) => {
return PLATFORM_SPECS ? Object.keys(PLATFORM_SPECS).includes(platform) : true
}
/**
* 필터 설정
*/
const setFilters = (newFilters) => {
filters.value = { ...filters.value, ...newFilters }
pagination.value.page = 1 // 필터 변경 시 첫 페이지로
}
/**
* 페이지네이션 설정
*/
const setPagination = (newPagination) => {
pagination.value = { ...pagination.value, ...newPagination }
}
/**
* 상태 초기화
*/
const resetState = () => {
contentList.value = []
contents.value = []
ongoingContents.value = []
selectedContent.value = null
generatedContent.value = null
totalCount.value = 0
filters.value = {
contentType: '',
platform: '',
period: '',
sortBy: 'latest'
}
pagination.value = {
page: 1,
itemsPerPage: 10
}
isLoading.value = false
loading.value = false loading.value = false
}
}
/**
* 템플릿으로 콘텐츠 생성
*/
const generateFromTemplate = async (templateId, customData = {}) => {
generating.value = true
try {
const result = await contentService.generateFromTemplate(templateId, customData)
if (result.success) {
generatedContent.value = result.data
return { success: true, data: result.data }
} else {
return { success: false, error: result.message }
}
} catch (error) {
console.error('❌ 템플릿 콘텐츠 생성 실패:', error)
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
generating.value = false generating.value = false
} }
}
// ===== 반환할 store 객체 =====
return { return {
// 상태 // 상태 (readonly로 보호)
contentList: readonly(contentList), contentList: readonly(contentList),
contents: readonly(contents), // ContentManagementView에서 사용 contents: readonly(contents), // ContentManagementView에서 사용
ongoingContents: readonly(ongoingContents), ongoingContents: readonly(ongoingContents),
@ -1038,41 +810,35 @@ export const useContentStore = defineStore('content', () => {
paginatedContents, paginatedContents,
totalPages, totalPages,
// 콘텐츠 목록 조회 // 기본 CRUD 액션들
loadContents, // 새로 추가된 메서드 (매장 정보 조회 포함) loadContents, // 새로 추가된 메서드 (매장 정보 조회 포함)
generateContent,
saveContent,
savePoster, // 포스터 전용 저장
saveSnsContent, // SNS 콘텐츠 전용 저장
fetchContentList, // 기존 호환성 유지 fetchContentList, // 기존 호환성 유지
// AI 콘텐츠 생성
generateContent, // 통합 메서드 (타입에 따라 라우팅)
generateSnsContent, // SNS 전용
generatePoster, // 포스터 전용
// 콘텐츠 저장
saveContent, // 통합 메서드 (타입에 따라 라우팅)
saveSnsContent, // SNS 전용
savePoster, // 포스터 전용
// 기본 CRUD
fetchOngoingContents, fetchOngoingContents,
fetchContentDetail, fetchContentDetail,
updateContent, updateContent,
deleteContent, deleteContent,
// 추가 액션들
publishContent,
fetchContentStats,
// 고급 콘텐츠 관리 기능들
searchContents,
getContentStats,
duplicateContent,
updateContentStatus,
toggleContentFavorite,
// 유틸리티 // 유틸리티
mapTargetToCategory, mapTargetToCategory,
getPlatformSpec, getPlatformSpec,
validatePlatform, validatePlatform,
setFilters, setFilters,
setPagination, setPagination,
reset, resetState
// 고급 기능
searchContents,
getContentStats,
duplicateContent,
updateContentStatus,
toggleContentFavorite,
getContentTemplates,
generateFromTemplate
} }
}) })

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;">
<!-- 책자 형식 레이아웃 --> <!-- 책자 형식 레이아웃 -->
@ -205,7 +207,7 @@
<!-- 요구사항 --> <!-- 요구사항 -->
<v-textarea <v-textarea
v-model="formData.requirements" v-model="formData.requirements"
label="구체적인 요구사항 (선택사항)" label="구체적인 요구사항"
variant="outlined" variant="outlined"
rows="3" rows="3"
density="compact" density="compact"
@ -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"
@ -386,6 +388,41 @@
<!-- 콘텐츠 내용 --> <!-- 콘텐츠 내용 -->
<div class="text-body-2 mb-3" style="line-height: 1.6;"> <div class="text-body-2 mb-3" style="line-height: 1.6;">
<!-- 포스터인 경우 이미지로 표시 -->
<div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
<v-img
v-if="currentVersion.posterImage || currentVersion.content"
:src="currentVersion.posterImage || currentVersion.content"
:alt="currentVersion.title"
cover
class="rounded-lg elevation-2 mb-3"
style="max-width: 100%; max-height: 300px; aspect-ratio: 3/4;"
@click="previewImage(currentVersion.posterImage || currentVersion.content, currentVersion.title)"
@error="handleImageError"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height bg-grey-lighten-4">
<v-progress-circular indeterminate color="primary" size="32" />
<span class="ml-2 text-grey">이미지 로딩 ...</span>
</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>
</div>
</div>
<!-- SNS인 경우 기존 텍스트 표시 -->
<div v-else>
<div v-if="isHtmlContent(currentVersion.content)" <div v-if="isHtmlContent(currentVersion.content)"
class="html-content preview-content"> class="html-content preview-content">
<div v-html="truncateHtmlContent(currentVersion.content, 200)"></div> <div v-html="truncateHtmlContent(currentVersion.content, 200)"></div>
@ -395,6 +432,7 @@
</div> </div>
<div v-else>{{ truncateText(currentVersion.content, 150) }}</div> <div v-else>{{ truncateText(currentVersion.content, 150) }}</div>
</div> </div>
</div>
<!-- 해시태그 --> <!-- 해시태그 -->
<div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-3"> <div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-3">
@ -466,9 +504,51 @@
<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="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
<v-img
v-if="currentVersion.posterImage || currentVersion.content"
:src="currentVersion.posterImage || currentVersion.content"
:alt="currentVersion.title"
cover
class="rounded-lg elevation-2"
style="max-width: 400px; aspect-ratio: 3/4; cursor: pointer;"
@click="previewImage(currentVersion.posterImage || currentVersion.content, currentVersion.title)"
@error="handleImageError"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height bg-grey-lighten-4">
<v-progress-circular indeterminate color="primary" size="32" />
<span class="ml-2 text-grey">이미지 로딩 ...</span>
</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>
<span class="text-caption text-grey mt-1" style="word-break: break-all; max-width: 200px;">
{{ (currentVersion.posterImage || currentVersion.content)?.substring(0, 50) }}...
</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>
<!-- SNS인 경우 기존 텍스트 표시 -->
<div v-else>
<div v-if="isHtmlContent(currentVersion.content)" <div v-if="isHtmlContent(currentVersion.content)"
class="pa-3 bg-grey-lighten-5 rounded html-content" class="pa-3 bg-grey-lighten-5 rounded html-content"
style="line-height: 1.6;" style="line-height: 1.6;"
@ -480,6 +560,7 @@
{{ currentVersion.content }} {{ currentVersion.content }}
</div> </div>
</div> </div>
</div>
<!-- 해시태그 --> <!-- 해시태그 -->
<div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-4"> <div v-if="currentVersion.hashtags && currentVersion.hashtags.length > 0" class="mb-4">
@ -583,12 +664,12 @@ const router = useRouter()
const contentStore = useContentStore() const contentStore = useContentStore()
const appStore = useAppStore() const appStore = useAppStore()
// // - isGenerating
const selectedType = ref('sns') const selectedType = ref('sns')
const formValid = ref(false)
const uploadedFiles = ref([]) const uploadedFiles = ref([])
const previewImages = ref([]) const previewImages = ref([])
const isPublishing = ref(false) const isPublishing = ref(false)
const isGenerating = ref(false) //
const publishingIndex = ref(-1) const publishingIndex = ref(-1)
const showDetailDialog = ref(false) const showDetailDialog = ref(false)
const selectedVersion = ref(0) const selectedVersion = ref(0)
@ -603,14 +684,28 @@ const formData = ref({
eventName: '', eventName: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
requirements: '', content: '',
hashtags: [],
category: '기타',
targetAge: '20대',
promotionStartDate: '', promotionStartDate: '',
promotionEndDate: '' promotionEndDate: '',
requirements: '',
toneAndManner: '친근함',
emotionIntensity: '보통',
imageStyle: '모던',
promotionType: '할인 정보',
photoStyle: '밝고 화사한'
}) })
// AI - // AI
const aiOptions = ref({ const aiOptions = ref({
targetAge: '20대' toneAndManner: 'friendly',
promotion: 'general',
emotionIntensity: 'normal',
photoStyle: '밝고 화사한',
imageStyle: '모던',
targetAge: '20대',
}) })
// //
@ -639,10 +734,9 @@ 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' }
] ]
// //
@ -713,7 +807,68 @@ const promotionEndDateRules = [
} }
] ]
// Computed // Computed
const formValid = computed(() => {
//
if (!formData.value.title || !formData.value.targetType) {
return false
}
// SNS
if (selectedType.value === 'sns' && !formData.value.platform) {
return false
}
//
if (formData.value.targetType === 'event') {
if (!formData.value.eventName || !formData.value.startDate || !formData.value.endDate) {
return false
}
}
//
if (selectedType.value === 'poster') {
if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) {
return false
}
//
if (!previewImages.value || previewImages.value.length === 0) {
return false
}
}
return true
})
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
}
})
const currentVersion = computed(() => { const currentVersion = computed(() => {
return generatedVersions.value[selectedVersion.value] || null return generatedVersions.value[selectedVersion.value] || null
}) })
@ -725,38 +880,76 @@ const selectContentType = (type) => {
} }
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) => {
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({ previewImages.value.push({
file, file: file,
url: e.target.result 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
}
} }
const generateContent = async () => { const generateContent = async () => {
@ -770,208 +963,73 @@ const generateContent = async () => {
return return
} }
try { isGenerating.value = true
console.log(`🎯 [UI] ${selectedType.value.toUpperCase()} 콘텐츠 생성 요청 시작`)
//
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)
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 { try {
console.log('🚀 [UI] 콘텐츠 생성 시작')
console.log('📋 [UI] 폼 데이터:', formData.value)
console.log('📁 [UI] 이미지 데이터:', previewImages.value)
// ID
let storeId = 1 //
try {
// localStorage
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}') const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
if (storeInfo.storeId) { if (storeInfo.storeId) {
actualStoreId = storeInfo.storeId storeId = storeInfo.storeId
} else if (userInfo.storeId) { } else if (userInfo.storeId) {
actualStoreId = userInfo.storeId storeId = userInfo.storeId
} else {
console.warn('⚠️ localStorage에서 매장 ID를 찾을 수 없음, 기본값 사용:', storeId)
} }
} catch (error) { } catch (error) {
console.warn('⚠️ [UI] 매장 정보 파싱 실패, 기본값 사용:', actualStoreId) console.warn('⚠️ 매장 정보 파싱 실패, 기본값 사용:', storeId)
} }
// ( ) console.log('🏪 [UI] 사용할 매장 ID:', storeId)
const processImages = () => {
const images = []
console.log('📁 [UI] 이미지 처리 시작...') // Base64 URL
const imageUrls = previewImages.value?.map(img => img.url).filter(url => url) || []
console.log('📁 [UI] 추출된 이미지 URL들:', imageUrls)
// previewImages //
if (!previewImages.value) { if (selectedType.value === 'poster' && imageUrls.length === 0) {
console.log('📁 [UI] previewImages.value가 null/undefined') throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.')
return images
} }
if (!Array.isArray(previewImages.value)) { //
console.log('📁 [UI] previewImages.value가 배열이 아님:', typeof previewImages.value) const contentData = {
return images title: formData.value.title,
} platform: formData.value.platform || (selectedType.value === 'poster' ? 'POSTER' : 'INSTAGRAM'),
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, contentType: selectedType.value,
type: selectedType.value, type: selectedType.value,
images: processedImages, // category: getCategory(formData.value.targetType),
category: '기타', requirement: formData.value.requirements || `${formData.value.title}에 대한 ${selectedType.value === 'poster' ? '포스터' : 'SNS 게시물'}를 만들어주세요`,
platform: '', targetType: formData.value.targetType,
targetAudience: '', targetAudience: formData.value.targetType,
requirements: formData.value.requirements || '', eventName: formData.value.eventName,
eventName: formData.value.eventName || '', eventDate: formData.value.eventDate,
startDate: formData.value.startDate || '', startDate: formData.value.startDate,
endDate: formData.value.endDate || '', endDate: formData.value.endDate,
targetAge: (aiOptions.value && aiOptions.value.targetAge) ? aiOptions.value.targetAge : '20대' toneAndManner: formData.value.toneAndManner || '친근함',
emotionIntensity: formData.value.emotionIntensity || '보통',
images: imageUrls, // Base64 URL
storeId: storeId // ID
} }
// //
if (selectedType.value === 'poster') { if (selectedType.value === 'poster') {
console.log('🎯 [UI] 포스터 생성 데이터 구성')
console.log('📁 [UI] 포스터용 이미지 체크:', {
processedImagesCount: processedImages.length,
previewImagesCount: previewImages.value?.length || 0,
hasImages: processedImages.length > 0
})
// ( )
if (processedImages.length === 0) {
console.error('❌ [UI] 포스터 이미지 없음!')
console.error('❌ [UI] previewImages 상태:', {
exists: !!previewImages.value,
isArray: Array.isArray(previewImages.value),
length: previewImages.value?.length,
content: previewImages.value
})
appStore.showSnackbar('포스터 생성을 위해 최소 1개의 이미지를 업로드해 주세요.', 'warning')
return
}
contentData.targetAudience = formData.value.targetType || 'menu'
contentData.category = getCategory(formData.value.targetType)
if (formData.value.promotionStartDate) {
contentData.promotionStartDate = formData.value.promotionStartDate contentData.promotionStartDate = formData.value.promotionStartDate
}
if (formData.value.promotionEndDate) {
contentData.promotionEndDate = formData.value.promotionEndDate contentData.promotionEndDate = formData.value.promotionEndDate
contentData.imageStyle = formData.value.imageStyle || '모던'
contentData.promotionType = formData.value.promotionType
contentData.photoStyle = formData.value.photoStyle || '밝고 화사한'
} }
} else { console.log('📤 [UI] 생성 요청 데이터:', contentData)
console.log('🎯 [UI] SNS 생성 데이터 구성')
if (!formData.value.platform) {
appStore.showSnackbar('플랫폼을 선택해주세요.', 'warning')
return
}
contentData.platform = formData.value.platform
contentData.targetType = formData.value.targetType || 'new_menu'
contentData.category = getCategory(formData.value.targetType)
}
//
console.log('📤 [UI] 최종 contentData:', {
type: contentData.type,
title: contentData.title,
platform: contentData.platform,
category: contentData.category,
storeId: contentData.storeId,
imageCount: contentData.images.length,
firstImagePreview: contentData.images[0]?.substring(0, 50)
})
//
if (!contentData.title) {
throw new Error('제목을 입력해주세요.')
}
if (selectedType.value === 'sns' && !contentData.platform) {
throw new Error('플랫폼을 선택해주세요.')
}
if (selectedType.value === 'poster' && contentData.images.length === 0) {
throw new Error('포스터 생성을 위해서는 최소 1개의 이미지가 필요합니다.')
}
// contentData // contentData
if (!contentData || typeof contentData !== 'object') { if (!contentData || typeof contentData !== 'object') {
@ -991,10 +1049,22 @@ const generateContent = async () => {
throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.') throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.')
} }
let finalContent = generated.content || generated.data?.content || '' //
let finalContent = ''
let posterImageUrl = ''
if (selectedType.value === 'poster') {
// generated.data URL
posterImageUrl = generated.data?.posterImage || generated.data?.content || generated.content || ''
finalContent = posterImageUrl // content URL
console.log('🖼️ [UI] 포스터 이미지 URL:', posterImageUrl)
} else {
// SNS
finalContent = generated.content || generated.data?.content || ''
// SNS // SNS
if (selectedType.value === 'sns' && contentData.images && contentData.images.length > 0) { if (contentData.images && contentData.images.length > 0) {
const imageHtml = contentData.images.map(imageUrl => const imageHtml = contentData.images.map(imageUrl =>
`<div style="margin-bottom: 15px; text-align: center;"> `<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);" /> <img src="${imageUrl}" style="width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);" />
@ -1007,15 +1077,19 @@ const generateContent = async () => {
finalContent = imageHtml + `<div style="padding: 15px; font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6;">${finalContent.replace(/\n/g, '<br>')}</div>` finalContent = imageHtml + `<div style="padding: 15px; font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6;">${finalContent.replace(/\n/g, '<br>')}</div>`
} }
} }
}
//
const newContent = { const newContent = {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
...contentData, ...contentData,
content: finalContent, content: finalContent,
posterImage: posterImageUrl, // URL
hashtags: generated.hashtags || generated.data?.hashtags || [], hashtags: generated.hashtags || generated.data?.hashtags || [],
createdAt: new Date(), createdAt: new Date(),
status: 'draft', status: 'draft',
uploadedImages: previewImages.value || [], uploadedImages: previewImages.value || [], //
images: imageUrls, // Base64 URL
platform: contentData.platform || 'POSTER' platform: contentData.platform || 'POSTER'
} }
@ -1029,6 +1103,8 @@ const generateContent = async () => {
console.error('❌ [UI] 콘텐츠 생성 실패:', error) console.error('❌ [UI] 콘텐츠 생성 실패:', error)
console.error('❌ [UI] 에러 스택:', error.stack) console.error('❌ [UI] 에러 스택:', error.stack)
appStore.showSnackbar(error.message || '콘텐츠 생성 중 오류가 발생했습니다.', 'error') appStore.showSnackbar(error.message || '콘텐츠 생성 중 오류가 발생했습니다.', 'error')
} finally {
isGenerating.value = false
} }
} }
@ -1037,7 +1113,9 @@ const getCategory = (targetType) => {
'new_menu': '메뉴소개', 'new_menu': '메뉴소개',
'discount': '이벤트', 'discount': '이벤트',
'store': '인테리어', 'store': '인테리어',
'event': '이벤트' 'event': '이벤트',
'menu': '메뉴소개',
'service': '서비스'
} }
return mapping[targetType] || '기타' return mapping[targetType] || '기타'
} }
@ -1053,16 +1131,140 @@ const saveVersion = async (index) => {
try { try {
const version = generatedVersions.value[index] const version = generatedVersions.value[index]
await contentStore.saveContent({ console.log('💾 [UI] 저장할 버전 데이터:', version)
// ID
let storeId = 1 //
try {
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
if (storeInfo.storeId) {
storeId = storeInfo.storeId
} else if (userInfo.storeId) {
storeId = userInfo.storeId
} else {
console.warn('⚠️ localStorage에서 매장 ID를 찾을 수 없음, 기본값 사용:', storeId)
}
} catch (error) {
console.warn('⚠️ 매장 정보 파싱 실패, 기본값 사용:', storeId)
}
console.log('🏪 [UI] 사용할 매장 ID:', storeId)
//
let imageUrls = []
// URL
if (selectedType.value === 'poster') {
// 1. URL
if (version.posterImage) {
imageUrls.push(version.posterImage)
console.log('💾 [UI] 생성된 포스터 이미지:', version.posterImage)
}
// 2. previewImages URL
if (previewImages.value && previewImages.value.length > 0) {
const originalImages = previewImages.value.map(img => img.url).filter(url => url)
imageUrls = [...imageUrls, ...originalImages]
console.log('💾 [UI] 원본 이미지들:', originalImages)
}
// 3. version
if (version.uploadedImages && version.uploadedImages.length > 0) {
const versionImages = version.uploadedImages.map(img => img.url).filter(url => url)
imageUrls = [...imageUrls, ...versionImages]
}
// 4. version.images
if (version.images && Array.isArray(version.images) && version.images.length > 0) {
imageUrls = [...imageUrls, ...version.images]
}
//
imageUrls = [...new Set(imageUrls)]
console.log('💾 [UI] 포스터 최종 이미지 URL들:', imageUrls)
//
if (!imageUrls || imageUrls.length === 0) {
throw new Error('포스터 저장을 위해 최소 1개의 이미지가 필요합니다.')
}
} else {
// SNS
if (previewImages.value && previewImages.value.length > 0) {
imageUrls = previewImages.value.map(img => img.url).filter(url => url)
}
if (version.images && Array.isArray(version.images)) {
imageUrls = [...new Set([...imageUrls, ...version.images])]
}
}
console.log('💾 [UI] 최종 이미지 URL들:', imageUrls)
// -
let saveData
if (selectedType.value === 'poster') {
// (PosterContentSaveRequest )
saveData = {
// ID
storeId: storeId,
// - content URL
title: version.title,
content: version.posterImage || version.content, // URL content
images: imageUrls, //
//
category: getCategory(version.targetType || formData.value.targetType),
requirement: formData.value.requirements || `${version.title}에 대한 포스터를 만들어주세요`,
//
eventName: version.eventName || formData.value.eventName,
startDate: formData.value.startDate,
endDate: formData.value.endDate,
//
photoStyle: formData.value.photoStyle || '밝고 화사한'
}
} else {
// SNS (SnsContentSaveRequest )
saveData = {
// ID
storeId: storeId,
//
contentType: 'SNS',
platform: version.platform || formData.value.platform || 'INSTAGRAM',
//
title: version.title, title: version.title,
content: version.content, content: version.content,
hashtags: version.hashtags, hashtags: version.hashtags || [],
platform: version.platform, images: imageUrls,
category: getCategory(version.targetType),
eventName: version.eventName, //
eventDate: version.eventDate, category: getCategory(version.targetType || formData.value.targetType),
requirement: formData.value.requirements || `${version.title}에 대한 SNS 게시물을 만들어주세요`,
toneAndManner: formData.value.toneAndManner || '친근함',
emotionIntensity: formData.value.emotionIntensity || '보통',
//
eventName: version.eventName || formData.value.eventName,
startDate: formData.value.startDate,
endDate: formData.value.endDate,
//
status: 'PUBLISHED' status: 'PUBLISHED'
}) }
}
console.log('💾 [UI] 최종 저장 데이터:', saveData)
//
await contentStore.saveContent(saveData)
version.status = 'published' version.status = 'published'
version.publishedAt = new Date() version.publishedAt = new Date()
@ -1127,7 +1329,8 @@ const getPlatformIcon = (platform) => {
'INSTAGRAM': 'mdi-instagram', 'INSTAGRAM': 'mdi-instagram',
'NAVER_BLOG': 'mdi-web', 'NAVER_BLOG': 'mdi-web',
'FACEBOOK': 'mdi-facebook', 'FACEBOOK': 'mdi-facebook',
'KAKAO_STORY': 'mdi-chat' 'KAKAO_STORY': 'mdi-chat',
'POSTER': 'mdi-image'
} }
return icons[platform] || 'mdi-web' return icons[platform] || 'mdi-web'
} }
@ -1141,7 +1344,8 @@ const getPlatformColor = (platform) => {
'INSTAGRAM': 'pink', 'INSTAGRAM': 'pink',
'NAVER_BLOG': 'green', 'NAVER_BLOG': 'green',
'FACEBOOK': 'blue', 'FACEBOOK': 'blue',
'KAKAO_STORY': 'amber' 'KAKAO_STORY': 'amber',
'POSTER': 'orange'
} }
return colors[platform] || 'grey' return colors[platform] || 'grey'
} }
@ -1155,7 +1359,8 @@ const getPlatformLabel = (platform) => {
'INSTAGRAM': '인스타그램', 'INSTAGRAM': '인스타그램',
'NAVER_BLOG': '네이버 블로그', 'NAVER_BLOG': '네이버 블로그',
'FACEBOOK': '페이스북', 'FACEBOOK': '페이스북',
'KAKAO_STORY': '카카오스토리' 'KAKAO_STORY': '카카오스토리',
'POSTER': '포스터'
} }
return labels[platform] || platform return labels[platform] || platform
} }
@ -1228,6 +1433,15 @@ const truncateHtmlContent = (html, maxLength) => {
return `<div style="padding: 10px; font-family: 'Noto Sans KR', Arial, sans-serif;">${truncateText(textContent, maxLength)}</div>` return `<div style="padding: 10px; font-family: 'Noto Sans KR', Arial, sans-serif;">${truncateText(textContent, maxLength)}</div>`
} }
const previewImage = (imageUrl, title) => {
if (!imageUrl) return
window.open(imageUrl, '_blank')
}
const handleImageError = (event) => {
console.error('❌ 이미지 로딩 실패:', event.target?.src)
}
// //
onMounted(() => { onMounted(() => {
console.log('📱 콘텐츠 생성 페이지 로드됨') console.log('📱 콘텐츠 생성 페이지 로드됨')
@ -1314,3 +1528,4 @@ onMounted(() => {
pointer-events: none; pointer-events: none;
} }
</style> </style>

View File

@ -152,8 +152,22 @@
{{ getStatusText(content.status) }} {{ getStatusText(content.status) }}
</v-chip> </v-chip>
</div> </div>
<!-- 포스터인 경우와 SNS인 경우 구분하여 미리보기 표시 -->
<div class="text-caption text-truncate grey--text" style="max-width: 400px;"> <div class="text-caption text-truncate grey--text" style="max-width: 400px;">
{{ content.content ? content.content.substring(0, 100) + '...' : '' }} <div v-if="content.platform === 'POSTER' || content.platform === 'poster'">
<div v-if="content.content && isImageUrl(content.content)" class="d-flex align-center">
<v-icon size="16" color="primary" class="mr-1">mdi-image</v-icon>
<span>포스터 이미지 생성됨</span>
</div>
<div v-else>
{{ content.content ? content.content.substring(0, 100) + '...' : '포스터 콘텐츠' }}
</div>
</div>
<!-- SNS인 경우 HTML 태그 제거하고 텍스트만 표시 -->
<div v-else>
{{ content.content ? getPlainTextPreview(content.content) : '' }}
</div>
</div> </div>
</div> </div>
</td> </td>
@ -272,9 +286,61 @@
</v-chip> </v-chip>
</div> </div>
<!-- 포스터인 경우 이미지로 표시, SNS인 경우 HTML 렌더링 -->
<div class="mb-4"> <div class="mb-4">
<div class="text-subtitle-2 text-grey-600 mb-1">내용</div> <div class="text-subtitle-2 text-grey-600 mb-1">내용</div>
<div class="text-body-1 content-preview">{{ selectedContent.content }}</div>
<!-- 포스터인 경우 이미지로 표시 -->
<div v-if="selectedContent.platform === 'POSTER' || selectedContent.platform === 'poster'">
<v-img
v-if="selectedContent.content && isImageUrl(selectedContent.content)"
:src="selectedContent.content"
:alt="selectedContent.title"
cover
class="rounded-lg elevation-2"
style="max-width: 400px; aspect-ratio: 3/4; cursor: pointer;"
@click="previewImage(selectedContent.content, selectedContent.title)"
@error="handleImageError"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height bg-grey-lighten-4">
<v-progress-circular indeterminate color="primary" size="32" />
<span class="ml-2 text-grey">이미지 로딩 ...</span>
</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>
<span class="text-caption text-grey mt-1" style="word-break: break-all; max-width: 300px;">
{{ selectedContent.content?.substring(0, 50) }}...
</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="selectedContent.content">
URL: {{ selectedContent.content }}
</span>
</div>
</div>
<!-- SNS인 경우 HTML 렌더링으로 표시 -->
<div v-else>
<!-- HTML 콘텐츠가 있는 경우 렌더링하여 표시 -->
<div v-if="isHtmlContent(selectedContent.content)"
class="content-preview html-content"
v-html="selectedContent.content">
</div>
<!-- 일반 텍스트인 경우 그대로 표시 -->
<div v-else class="text-body-1 content-preview">
{{ selectedContent.content }}
</div>
</div>
</div> </div>
<div class="mb-4" v-if="selectedContent.hashtags && selectedContent.hashtags.length > 0"> <div class="mb-4" v-if="selectedContent.hashtags && selectedContent.hashtags.length > 0">
@ -664,7 +730,78 @@ const deleteSelectedItems = async () => {
} }
} }
// //
/**
* URL이 이미지 URL인지 확인
*/
const isImageUrl = (url) => {
if (!url || typeof url !== 'string') return false
//
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg']
const lowerUrl = url.toLowerCase()
//
if (imageExtensions.some(ext => lowerUrl.includes(ext))) {
return true
}
// Blob URL
if (url.startsWith('blob:') || url.startsWith('data:image/')) {
return true
}
// Azure Blob Storage URL
if (url.includes('blob.core.windows.net') ||
url.includes('amazonaws.com') ||
url.includes('googleusercontent.com') ||
url.includes('cloudinary.com')) {
return true
}
return false
}
/**
* HTML 콘텐츠인지 확인
*/
const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false
// HTML
const htmlTagRegex = /<[^>]+>/g
return htmlTagRegex.test(content)
}
/**
* HTML 태그를 제거하고 텍스트만 추출하여 미리보기용으로 반환
*/
const getPlainTextPreview = (content) => {
if (!content) return ''
// HTML
const textContent = content.replace(/<[^>]*>/g, '').trim()
// 100 ...
return textContent.length > 100 ? textContent.substring(0, 100) + '...' : textContent
}
/**
* 이미지 미리보기
*/
const previewImage = (imageUrl, title) => {
if (!imageUrl) return
window.open(imageUrl, '_blank')
}
/**
* 이미지 로딩 에러 처리
*/
const handleImageError = (event) => {
console.error('❌ 이미지 로딩 실패:', event.target?.src)
}
//
const getStatusColor = (status) => { const getStatusColor = (status) => {
const statusColors = { const statusColors = {
'DRAFT': 'orange', 'DRAFT': 'orange',
@ -783,4 +920,61 @@ onMounted(() => {
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
/* ✅ HTML 콘텐츠 스타일링 */
:deep(.html-content) {
font-family: 'Noto Sans KR', Arial, sans-serif;
line-height: 1.6;
padding: 20px;
max-width: 600px;
}
:deep(.html-content h1),
:deep(.html-content h2),
:deep(.html-content h3) {
margin: 0 0 10px 0;
font-weight: bold;
}
:deep(.html-content h3) {
font-size: 18px;
color: #1976d2;
}
:deep(.html-content p) {
margin: 0 0 10px 0;
}
:deep(.html-content div[style*="background"]) {
border-radius: 10px;
padding: 15px;
margin: 10px 0;
}
:deep(.html-content div[style*="border"]) {
border-radius: 8px;
padding: 20px;
margin: 10px 0;
border: 1px solid #e1e8ed;
}
:deep(.html-content img) {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin: 20px 0;
}
:deep(.html-content span[style*="#1DA1F2"]) {
color: #1976d2 !important;
}
:deep(.html-content span[style*="#1EC800"]) {
color: #4caf50 !important;
}
:deep(.html-content span[style*="#00B33C"]) {
color: #2e7d32 !important;
}
</style> </style>

View File

@ -252,12 +252,7 @@
<div v-else-if="aiRecommendation" class="ai-recommendation-content"> <div v-else-if="aiRecommendation" class="ai-recommendation-content">
<!-- 추천 제목 --> <!-- 추천 제목 -->
<div class="recommendation-header mb-4"> <div class="recommendation-header mb-4">
<div class="d-flex align-center mb-2">
<span class="recommendation-emoji mr-2">{{ aiRecommendation.emoji }}</span>
<h4 class="text-h6 font-weight-bold text-primary">
{{ aiRecommendation.title }}
</h4>
</div>
</div> </div>
<!-- 스크롤 가능한 콘텐츠 영역 --> <!-- 스크롤 가능한 콘텐츠 영역 -->

View File

@ -167,7 +167,7 @@ const generatePoster = async (formData) => {
} }
/** /**
* 포스터 저장 * 포스터 저장 - 수정된 버전
*/ */
const savePoster = async () => { const savePoster = async () => {
if (!generatedPoster.value) { if (!generatedPoster.value) {
@ -181,12 +181,22 @@ const savePoster = async () => {
loadingMessage.value = '포스터 저장 중...' loadingMessage.value = '포스터 저장 중...'
loadingSubMessage.value = '잠시만 기다려주세요' loadingSubMessage.value = '잠시만 기다려주세요'
// contentId
const result = await posterStore.savePoster({ const result = await posterStore.savePoster({
contentId: generatedPoster.value.contentId, // contentId: ID ( @NotNull)
contentId: generatedPoster.value.contentId || Date.now(), // ID
storeId: authStore.currentStore?.id || 1, storeId: authStore.currentStore?.id || 1,
title: posterForm.value.title, title: posterForm.value.title,
content: generatedPoster.value.content,
images: [generatedPoster.value.posterImage], //
content: generatedPoster.value.content || generatedPoster.value.description || posterForm.value.title,
//
images: [
...posterForm.value.images, //
generatedPoster.value.posterImage || generatedPoster.value.imageUrl //
].filter(img => img), // null/undefined
status: 'PUBLISHED', status: 'PUBLISHED',
category: posterForm.value.category, category: posterForm.value.category,
requirement: posterForm.value.requirement, requirement: posterForm.value.requirement,