diff --git a/src/services/content.js b/src/services/content.js index 12eb44f..781cad2 100644 --- a/src/services/content.js +++ b/src/services/content.js @@ -333,7 +333,7 @@ class ContentService { // ✅ API 호출 const response = await contentApi.post('/sns/generate', formData, { - timeout: 30000, + timeout: 0, headers: { 'Content-Type': 'multipart/form-data' } diff --git a/src/views/ContentCreationView.vue b/src/views/ContentCreationView.vue index 4368574..f098141 100644 --- a/src/views/ContentCreationView.vue +++ b/src/views/ContentCreationView.vue @@ -1,4 +1,4 @@ -//* src/views/ContentCreationView.vue - 수정된 완전한 파일 +//* src/views/ContentCreationView.vue - 완전 통합 버전 - + + + + @update:model-value="handleTargetTypeChange" + > + + - + - - + + @@ -388,7 +432,7 @@
- +
- +
@@ -467,7 +511,7 @@ mdi-content-copy 복사 @@ -504,11 +548,11 @@ - +

콘텐츠

- +
- +
홍보 대상 - + 이벤트명 + + + 음식명 + @@ -639,7 +689,7 @@ - +

AI가 콘텐츠를 생성 중입니다

@@ -664,23 +714,25 @@ const router = useRouter() const contentStore = useContentStore() const appStore = useAppStore() -// ✅ 반응형 데이터 - isGenerating 추가 +// 반응형 데이터 const selectedType = ref('sns') const uploadedFiles = ref([]) const previewImages = ref([]) const isPublishing = ref(false) -const isGenerating = ref(false) // ✅ 추가 +const isGenerating = ref(false) const publishingIndex = ref(-1) const showDetailDialog = ref(false) const selectedVersion = ref(0) const generatedVersions = ref([]) const remainingGenerations = ref(3) +const formValid = ref(false) // 폼 데이터 const formData = ref({ title: '', platform: '', targetType: '', + menuName: '', eventName: '', startDate: '', endDate: '', @@ -713,7 +765,7 @@ const contentTypes = [ { value: 'sns', label: 'SNS 게시물', - description: '인스타그램, 페이스북 등', + description: '인스타그램, 네이버블로그 등', icon: 'mdi-instagram', color: 'pink' }, @@ -728,15 +780,13 @@ const contentTypes = [ const platformOptions = [ { title: '인스타그램', value: 'instagram' }, - { title: '네이버 블로그', value: 'naver_blog' }, - { title: '페이스북', value: 'facebook' }, - { title: '카카오스토리', value: 'kakao_story' } + { title: '네이버 블로그', value: 'naver_blog' } ] const targetTypes = [ { title: '메뉴', value: 'menu' }, { title: '매장', value: 'store' }, - { title: '이벤트', value: 'event' }, + { title: '이벤트', value: 'event' } ] // 타겟 연령층 옵션 @@ -754,13 +804,34 @@ const getTargetTypes = (type) => { if (type === 'poster') { return [ { title: '메뉴', value: 'menu' }, - { title: '이벤트', value: 'event' }, { title: '매장', value: 'store' }, + { title: '이벤트', value: 'event' }, { title: '서비스', value: 'service' }, { title: '할인혜택', value: 'discount' } ] - } else { - return targetTypes + } + // SNS + return [ + { title: '메뉴', value: 'menu' }, + { title: '매장', value: 'store' }, + { title: '이벤트', value: 'event' } + ] +} + +// 포스터 대상 선택 제한 함수들 (첫 번째 파일에서 추가) +const handleTargetItemClick = (value, event) => { + if (selectedType.value === 'poster' && value !== 'menu') { + event.preventDefault() + event.stopPropagation() + appStore.showSnackbar('현재 포스터는 메뉴 대상만 지원됩니다.', 'warning') + return false + } +} + +const handleTargetTypeChange = (value) => { + if (selectedType.value === 'poster' && value !== 'menu') { + formData.value.targetType = 'menu' + appStore.showSnackbar('현재 포스터는 메뉴 대상만 지원됩니다.', 'warning') } } @@ -778,6 +849,11 @@ const targetRules = [ v => !!v || '홍보 대상을 선택해주세요' ] +const menuNameRules = [ + v => !!v || '음식명은 필수입니다', + v => (v && v.length <= 50) || '음식명은 50자 이하로 입력해주세요' +] + const eventNameRules = [ v => !formData.value.targetType || formData.value.targetType !== 'event' || !!v || '이벤트명은 필수입니다' ] @@ -807,51 +883,18 @@ const promotionEndDateRules = [ } ] -// ✅ 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 -}) - +// 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 (!formData.value.menuName) return false if (!previewImages.value || previewImages.value.length === 0) return false if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) return false } @@ -876,19 +919,60 @@ const currentVersion = computed(() => { // 메서드 const selectContentType = (type) => { selectedType.value = type - console.log(`${type} 타입 선택됨`) + console.log(`${type} 타입 선택됨 - 폼 데이터 초기화`) + + // ✅ 폼 데이터만 초기화 (생성된 콘텐츠는 보존) + formData.value = { + title: '', + platform: '', + targetType: type === 'poster' ? 'menu' : '', // 포스터는 메뉴로 기본 설정 + menuName: '', + eventName: '', + startDate: '', + endDate: '', + content: '', + hashtags: [], + category: '기타', + targetAge: '20대', + promotionStartDate: '', + promotionEndDate: '', + requirements: '', + toneAndManner: '친근함', + emotionIntensity: '보통', + imageStyle: '모던', + promotionType: '할인 정보', + photoStyle: '밝고 화사한' + } + + // ✅ 이미지 업로드 상태도 초기화 + uploadedFiles.value = [] + previewImages.value = [] + + // ✅ AI 옵션도 초기화 + aiOptions.value = { + toneAndManner: 'friendly', + promotion: 'general', + emotionIntensity: 'normal', + photoStyle: '밝고 화사한', + imageStyle: '모던', + targetAge: '20대', + } + + console.log('✅ 폼 데이터 초기화 완료:', { + type: type, + targetType: formData.value.targetType, + preservedVersions: generatedVersions.value.length + }) } const handleFileUpload = (files) => { console.log('📁 파일 업로드 이벤트:', files) - // 파일이 없는 경우 처리 if (!files || (Array.isArray(files) && files.length === 0)) { console.log('📁 파일이 없음 - 기존 이미지 유지') return } - // 파일 배열로 변환 let fileArray = [] if (files instanceof FileList) { fileArray = Array.from(files) @@ -901,10 +985,8 @@ const handleFileUpload = (files) => { console.log('📁 처리할 파일 개수:', fileArray.length) - // 기존 이미지 완전히 초기화 (중복 방지) previewImages.value = [] - // 각 파일 개별 처리 fileArray.forEach((file, index) => { if (file && file.type && file.type.startsWith('image/')) { const reader = new FileReader() @@ -912,11 +994,9 @@ const handleFileUpload = (files) => { 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({ file: file, url: e.target.result, @@ -944,7 +1024,6 @@ const removeImage = (index) => { console.log('🗑️ 이미지 삭제:', index) previewImages.value.splice(index, 1) - // 업로드된 파일 목록도 업데이트 if (uploadedFiles.value && uploadedFiles.value.length > index) { const newFiles = Array.from(uploadedFiles.value) newFiles.splice(index, 1) @@ -953,7 +1032,7 @@ const removeImage = (index) => { } const generateContent = async () => { - if (!formValid.value) { + if (!canGenerate.value) { appStore.showSnackbar('모든 필수 항목을 입력해주세요.', 'warning') return } @@ -963,6 +1042,13 @@ const generateContent = async () => { return } + // 포스터의 경우 메뉴 대상만 허용하는 최종 검증 + if (selectedType.value === 'poster' && formData.value.targetType !== 'menu') { + appStore.showSnackbar('포스터는 메뉴 대상만 생성 가능합니다.', 'warning') + formData.value.targetType = 'menu' + return + } + isGenerating.value = true try { @@ -970,11 +1056,10 @@ const generateContent = async () => { console.log('📋 [UI] 폼 데이터:', formData.value) console.log('📁 [UI] 이미지 데이터:', previewImages.value) - // ✅ 매장 ID 가져오기 - let storeId = 1 // 기본값 + // 매장 ID 가져오기 + let storeId = 1 try { - // localStorage에서 매장 정보 조회 시도 const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}') const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}') @@ -991,16 +1076,16 @@ const generateContent = async () => { console.log('🏪 [UI] 사용할 매장 ID:', storeId) - // ✅ Base64 이미지 URL 추출 + // Base64 이미지 URL 추출 const imageUrls = previewImages.value?.map(img => img.url).filter(url => url) || [] console.log('📁 [UI] 추출된 이미지 URL들:', imageUrls) - // ✅ 포스터 타입의 경우 이미지 필수 검증 + // 포스터 타입의 경우 이미지 필수 검증 if (selectedType.value === 'poster' && imageUrls.length === 0) { throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.') } - // ✅ 콘텐츠 생성 데이터 구성 + // 콘텐츠 생성 데이터 구성 const contentData = { title: formData.value.title, platform: formData.value.platform || (selectedType.value === 'poster' ? 'POSTER' : 'INSTAGRAM'), @@ -1016,22 +1101,27 @@ const generateContent = async () => { endDate: formData.value.endDate, toneAndManner: formData.value.toneAndManner || '친근함', emotionIntensity: formData.value.emotionIntensity || '보통', - images: imageUrls, // ✅ Base64 이미지 URL 배열 - storeId: storeId // ✅ 매장 ID 추가 + images: imageUrls, + storeId: storeId } - // ✅ 포스터 전용 필드 추가 + // 포스터 전용 필드 추가 if (selectedType.value === 'poster') { - contentData.promotionStartDate = formData.value.promotionStartDate - contentData.promotionEndDate = formData.value.promotionEndDate - contentData.imageStyle = formData.value.imageStyle || '모던' - contentData.promotionType = formData.value.promotionType - contentData.photoStyle = formData.value.photoStyle || '밝고 화사한' + contentData.menuName = formData.value.menuName.trim() + contentData.targetAudience = aiOptions.value.targetAge || '20대' + contentData.category = '메뉴소개' + + if (formData.value.promotionStartDate) { + contentData.promotionStartDate = new Date(formData.value.promotionStartDate).toISOString() + } + if (formData.value.promotionEndDate) { + contentData.promotionEndDate = new Date(formData.value.promotionEndDate).toISOString() + } } console.log('📤 [UI] 생성 요청 데이터:', contentData) - // ✅ contentData 무결성 체크 + // contentData 무결성 체크 if (!contentData || typeof contentData !== 'object') { throw new Error('콘텐츠 데이터 구성에 실패했습니다.') } @@ -1041,7 +1131,7 @@ const generateContent = async () => { contentData.images = [] } - // ✅ Store 호출 + // Store 호출 console.log('🚀 [UI] contentStore.generateContent 호출') const generated = await contentStore.generateContent(contentData) @@ -1049,18 +1139,16 @@ const generateContent = async () => { throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.') } - // ✅ 포스터 생성 결과 처리 개선 + // 포스터 생성 결과 처리 개선 let finalContent = '' let posterImageUrl = '' if (selectedType.value === 'poster') { - // 포스터의 경우 generated.data에서 이미지 URL 추출 posterImageUrl = generated.data?.posterImage || generated.data?.content || generated.content || '' - finalContent = posterImageUrl // content 필드에 이미지 URL 저장 + finalContent = posterImageUrl console.log('🖼️ [UI] 포스터 이미지 URL:', posterImageUrl) } else { - // SNS의 경우 기존 로직 유지 finalContent = generated.content || generated.data?.content || '' // SNS용 이미지 추가 @@ -1079,18 +1167,19 @@ const generateContent = async () => { } } - // ✅ 생성된 콘텐츠 객체에 이미지 정보 포함 + // 생성된 콘텐츠 객체에 이미지 정보 포함 const newContent = { id: Date.now() + Math.random(), ...contentData, content: finalContent, - posterImage: posterImageUrl, // 포스터 이미지 URL 별도 저장 + posterImage: posterImageUrl, hashtags: generated.hashtags || generated.data?.hashtags || [], createdAt: new Date(), status: 'draft', - uploadedImages: previewImages.value || [], // ✅ 업로드된 이미지 정보 보존 - images: imageUrls, // ✅ Base64 URL 보존 - platform: contentData.platform || 'POSTER' + uploadedImages: previewImages.value || [], + images: imageUrls, + platform: contentData.platform || 'POSTER', + menuName: formData.value.menuName || '' } generatedVersions.value.push(newContent) @@ -1133,8 +1222,8 @@ const saveVersion = async (index) => { console.log('💾 [UI] 저장할 버전 데이터:', version) - // ✅ 매장 ID 가져오기 - let storeId = 1 // 기본값 + // 매장 ID 가져오기 + let storeId = 1 try { const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}') @@ -1153,46 +1242,38 @@ const saveVersion = async (index) => { 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) } @@ -1203,67 +1284,44 @@ const saveVersion = async (index) => { 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, // 모든 관련 이미지들 - - // 분류 정보 + content: version.posterImage || version.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, content: version.content, hashtags: version.hashtags || [], images: imageUrls, - - // 분류 정보 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' } } console.log('💾 [UI] 최종 저장 데이터:', saveData) - // ✅ 저장 실행 await contentStore.saveContent(saveData) version.status = 'published' @@ -1297,18 +1355,32 @@ const copyToClipboard = async (content) => { } } +// 개선된 복사 기능 - 포스터와 SNS 구분하여 처리 const copyFullContent = async (version) => { try { let fullContent = '' - if (isHtmlContent(version.content)) { - fullContent += extractTextFromHtml(version.content) + // 포스터인 경우 제목과 간단한 설명만 복사 + if (selectedType.value === 'poster' || version.contentType === 'poster' || version.type === 'poster') { + fullContent = version.title || '포스터' + if (formData.value.requirements) { + fullContent += '\n\n' + formData.value.requirements + } + if (version.posterImage || version.content) { + fullContent += '\n\n포스터 이미지: ' + (version.posterImage || version.content) + } } else { - fullContent += version.content - } - - if (version.hashtags && version.hashtags.length > 0) { - fullContent += '\n\n' + version.hashtags.join(' ') + // SNS 콘텐츠인 경우 HTML 태그 제거하고 텍스트만 추출 + if (isHtmlContent(version.content)) { + fullContent += extractTextFromHtml(version.content) + } else { + fullContent += version.content || '' + } + + // 해시태그 추가 + if (version.hashtags && version.hashtags.length > 0) { + fullContent += '\n\n' + version.hashtags.join(' ') + } } await navigator.clipboard.writeText(fullContent) @@ -1352,14 +1424,8 @@ const getPlatformColor = (platform) => { const getPlatformLabel = (platform) => { const labels = { - 'instagram': '인스타그램', - 'naver_blog': '네이버 블로그', - 'facebook': '페이스북', - 'kakao_story': '카카오스토리', 'INSTAGRAM': '인스타그램', 'NAVER_BLOG': '네이버 블로그', - 'FACEBOOK': '페이스북', - 'KAKAO_STORY': '카카오스토리', 'POSTER': '포스터' } return labels[platform] || platform @@ -1407,11 +1473,28 @@ const isHtmlContent = (content) => { return /<[^>]+>/.test(content) } +// 개선된 HTML 텍스트 추출 함수 const extractTextFromHtml = (html) => { if (!html) return '' - const tempDiv = document.createElement('div') - tempDiv.innerHTML = html - return tempDiv.textContent || tempDiv.innerText || '' + + try { + // HTML 태그를 제거하고 텍스트만 추출 + const textContent = html + .replace(//gi, '\n') //
태그를 줄바꿈으로 + .replace(/<\/p>/gi, '\n\n') //

태그를 두 줄바꿈으로 + .replace(/<[^>]*>/g, '') // 모든 HTML 태그 제거 + .replace(/ /g, ' ') //   를 공백으로 + .replace(/&/g, '&') // & 를 &로 + .replace(/</g, '<') // < 를 <로 + .replace(/>/g, '>') // > 를 >로 + .replace(/"/g, '"') // " 를 "로 + .trim() + + return textContent + } catch (error) { + console.error('HTML 텍스트 추출 실패:', error) + return html + } } const truncateHtmlContent = (html, maxLength) => { @@ -1445,7 +1528,43 @@ const handleImageError = (event) => { // 라이프사이클 onMounted(() => { console.log('📱 콘텐츠 생성 페이지 로드됨') + + // 초기 상태 확인 + console.log('🔍 초기 상태 확인:') + console.log('- selectedType:', selectedType.value) + console.log('- formData:', formData.value) + console.log('- previewImages:', previewImages.value) + console.log('- canGenerate 존재:', typeof canGenerate) + + // 5초 후 상태 재확인 + setTimeout(() => { + console.log('🔍 5초 후 상태:') + console.log('- formData.title:', formData.value.title) + console.log('- formData.menuName:', formData.value.menuName) + console.log('- canGenerate:', canGenerate?.value) + }, 5000) }) + +// 실시간 formData 변화 감지 +watch(() => formData.value, (newVal) => { + console.log('📝 formData 실시간 변경:', { + title: newVal.title, + menuName: newVal.menuName, + targetType: newVal.targetType, + promotionStartDate: newVal.promotionStartDate, + promotionEndDate: newVal.promotionEndDate + }) +}, { deep: true }) + +// canGenerate 변화 감지 +watch(canGenerate, (newVal) => { + console.log('🎯 canGenerate 변경:', newVal) +}) + +// previewImages 변화 감지 +watch(() => previewImages.value, (newVal) => { + console.log('📁 previewImages 변경:', newVal.length, '개') +}, { deep: true }) - \ No newline at end of file + \ No newline at end of file diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index 6a18c62..4865e34 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -232,14 +232,6 @@

맞춤형 마케팅 제안

-