Merge pull request #1 from won-ktds/front

Front
This commit is contained in:
yyoooona 2025-06-19 09:25:33 +09:00 committed by GitHub
commit da0c78cce6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 6604 additions and 3165 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,407 @@
//* src/components/poster/PosterForm.vue
<template>
<v-form ref="form" v-model="valid" @submit.prevent="handleGenerate">
<!-- 기본 정보 -->
<div class="mb-6">
<h3 class="text-h6 mb-4">
<v-icon class="mr-2">mdi-information</v-icon>
기본 정보
</h3>
<v-text-field
v-model="formData.title"
label="포스터 제목"
placeholder="예: 신메뉴 출시 이벤트"
:rules="titleRules"
variant="outlined"
class="mb-4"
/>
<v-select
v-model="formData.targetAudience"
label="홍보 대상"
:items="targetAudienceOptions"
variant="outlined"
class="mb-4"
/>
<v-textarea
v-model="formData.requirement"
label="구체적인 요구사항"
placeholder="어떤 포스터를 원하시는지 자세히 설명해주세요"
rows="3"
variant="outlined"
class="mb-4"
/>
</div>
<!-- 프로모션 정보 -->
<div class="mb-6">
<h3 class="text-h6 mb-4">
<v-icon class="mr-2">mdi-calendar-range</v-icon>
프로모션 정보
</h3>
<v-text-field
v-model="formData.eventName"
label="이벤트명"
placeholder="예: 신메뉴 출시 기념 이벤트"
variant="outlined"
class="mb-4"
/>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="formData.promotionStartDate"
label="홍보 시작일"
type="datetime-local"
variant="outlined"
:rules="startDateRules"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="formData.promotionEndDate"
label="홍보 종료일"
type="datetime-local"
variant="outlined"
:rules="endDateRules"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="formData.startDate"
label="이벤트 시작일"
type="date"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="formData.endDate"
label="이벤트 종료일"
type="date"
variant="outlined"
/>
</v-col>
</v-row>
</div>
<!-- 디자인 설정 -->
<div class="mb-6">
<h3 class="text-h6 mb-4">
<v-icon class="mr-2">mdi-palette</v-icon>
디자인 설정
</h3>
<v-row>
<v-col cols="12" sm="6">
<v-select
v-model="formData.imageStyle"
label="이미지 스타일"
:items="imageStyleOptions"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="formData.photoStyle"
label="사진 스타일"
:items="photoStyleOptions"
variant="outlined"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6">
<v-select
v-model="formData.promotionType"
label="프로모션 유형"
:items="promotionTypeOptions"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="formData.emotionIntensity"
label="감정 강도"
:items="emotionIntensityOptions"
variant="outlined"
/>
</v-col>
</v-row>
<v-select
v-model="formData.toneAndManner"
label="톤앤매너"
:items="toneAndMannerOptions"
variant="outlined"
class="mb-4"
/>
<v-select
v-model="formData.category"
label="카테고리"
:items="categoryOptions"
variant="outlined"
class="mb-4"
/>
</div>
<!-- 이미지 업로드 -->
<div class="mb-6">
<h3 class="text-h6 mb-4">
<v-icon class="mr-2">mdi-image-multiple</v-icon>
이미지 업로드
</h3>
<v-file-input
v-model="uploadedFiles"
label="이미지 선택"
placeholder="포스터에 포함할 이미지를 선택하세요"
accept="image/*"
multiple
show-size
variant="outlined"
prepend-icon="mdi-camera"
@change="handleFileUpload"
:rules="imageRules"
class="mb-4"
/>
<!-- 업로드된 이미지 미리보기 -->
<div v-if="formData.images.length > 0" class="mb-4">
<v-row>
<v-col
v-for="(image, index) in formData.images"
:key="index"
cols="6"
sm="4"
md="3"
>
<v-card class="elevation-2">
<v-img
:src="image"
height="120"
cover
/>
<v-card-actions class="pa-2">
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="removeImage(index)"
/>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
</div>
<!-- 액션 버튼 -->
<div class="d-flex gap-3">
<v-btn
color="primary"
size="large"
variant="elevated"
:loading="loading"
:disabled="!valid || formData.images.length === 0"
@click="handleGenerate"
class="flex-grow-1"
>
<v-icon class="mr-2">mdi-magic-staff</v-icon>
AI 포스터 생성
</v-btn>
<v-btn
color="success"
size="large"
variant="elevated"
:disabled="!canSave"
@click="handleSave"
class="flex-grow-1"
>
<v-icon class="mr-2">mdi-content-save</v-icon>
포스터 저장
</v-btn>
</div>
</v-form>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
/**
* 포스터 생성 컴포넌트
* - 포스터 정보 입력
* - 이미지 업로드
* - 유효성 검증
*/
// Props
const props = defineProps({
modelValue: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'generate', 'save'])
//
const form = ref(null)
const valid = ref(false)
const uploadedFiles = ref([])
//
const formData = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const canSave = computed(() => {
return formData.value.title && formData.value.images.length > 0
})
//
const targetAudienceOptions = [
'메뉴', '이벤트', '매장', '서비스', '할인혜택'
]
const imageStyleOptions = [
'모던', '클래식', '미니멀', '화려한', '자연스러운', '빈티지'
]
const photoStyleOptions = [
'밝고 화사한', '따뜻한 톤', '차분한 톤', '선명하고 대비가 강한', '부드러운 톤', '시크한 톤'
]
const promotionTypeOptions = [
'할인 정보', '신메뉴 소개', '이벤트 안내', '매장 홍보', '서비스 안내'
]
const emotionIntensityOptions = [
'약함', '보통', '강함', '매우 강함'
]
const toneAndMannerOptions = [
'전문적', '친근한', '활기찬', '고급스러운', '캐주얼한', '신뢰감 있는'
]
const categoryOptions = [
'이벤트', '메뉴', '할인', '공지', '홍보'
]
//
const titleRules = [
v => !!v || '제목은 필수입니다',
v => (v && v.length <= 100) || '제목은 100자 이하로 입력해주세요'
]
const startDateRules = [
v => !!v || '홍보 시작일은 필수입니다'
]
const endDateRules = [
v => !!v || '홍보 종료일은 필수입니다',
v => {
if (!v || !formData.value.promotionStartDate) return true
return new Date(v) > new Date(formData.value.promotionStartDate) || '종료일은 시작일보다 늦어야 합니다'
}
]
const imageRules = [
v => formData.value.images.length > 0 || '최소 1개의 이미지를 업로드해야 합니다'
]
/**
* 파일 업로드 처리 - 오류 수정된 버전
*/
const handleFileUpload = (files) => {
console.log('📁 파일 업로드 시작:', files)
//
if (!files || files.length === 0) {
console.log('파일이 선택되지 않음')
return
}
// FileList
const fileArray = Array.from(files)
console.log('📁 변환된 파일 배열:', fileArray)
const newImages = []
let processedCount = 0
fileArray.forEach((file, index) => {
//
if (file && file.type && file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => {
newImages.push(e.target.result)
processedCount++
//
if (processedCount === fileArray.length) {
console.log('📁 모든 이미지 처리 완료:', newImages.length)
//
formData.value.images.push(...newImages)
}
}
reader.onerror = (error) => {
console.error('파일 읽기 오류:', error)
processedCount++
}
reader.readAsDataURL(file)
} else {
console.warn('지원하지 않는 파일 형식:', file?.type)
processedCount++
}
})
}
/**
* 이미지 제거
*/
const removeImage = (index) => {
formData.value.images.splice(index, 1)
}
/**
* 포스터 생성 요청
*/
const handleGenerate = () => {
if (form.value?.validate()) {
emit('generate', formData.value)
}
}
/**
* 포스터 저장 요청
*/
const handleSave = () => {
emit('save')
}
//
watch(() => formData.value.promotionStartDate, () => {
if (form.value) {
form.value.validate()
}
})
</script>

View File

@ -0,0 +1,318 @@
//* src/components/poster/PosterPreview.vue
<template>
<div class="poster-preview">
<!-- 로딩 상태 -->
<div v-if="loading" class="text-center py-12">
<v-progress-circular
indeterminate
size="64"
color="primary"
class="mb-4"
/>
<div class="text-h6">포스터 생성 ...</div>
<div class="text-body-2 text-grey-600 mt-2">AI가 멋진 포스터를 만들고 있어요</div>
</div>
<!-- 포스터 없음 -->
<div v-else-if="!posterData" class="text-center py-12">
<v-icon size="80" color="grey-lighten-2" class="mb-4">
mdi-image-outline
</v-icon>
<div class="text-h6 mb-2">포스터 미리보기</div>
<div class="text-body-2 text-grey-600">
좌측 폼을 작성하고 'AI 포스터 생성' 버튼을 클릭하세요
</div>
</div>
<!-- 생성된 포스터 -->
<div v-else class="poster-result">
<!-- 포스터 이미지 -->
<div class="poster-image-container mb-4">
<v-img
:src="posterData.posterImage || '/images/placeholder-poster.jpg'"
:alt="posterData.title"
cover
class="rounded-lg elevation-4"
style="aspect-ratio: 3/4; max-height: 400px;"
>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate />
</div>
</template>
<template v-slot:error>
<div class="d-flex align-center justify-center fill-height bg-grey-lighten-3">
<v-icon size="48" color="grey">mdi-image-broken</v-icon>
</div>
</template>
</v-img>
<!-- 이미지 액션 버튼 -->
<div class="image-actions mt-3">
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-download"
@click="downloadPoster"
class="mr-2"
>
다운로드
</v-btn>
<v-btn
size="small"
variant="outlined"
prepend-icon="mdi-share-variant"
@click="sharePoster"
>
공유
</v-btn>
</div>
</div>
<!-- 포스터 정보 -->
<v-card variant="outlined" class="mb-4">
<v-card-title class="text-h6">
{{ posterData.title }}
</v-card-title>
<v-card-text>
<div v-if="posterData.content" class="mb-3">
<div class="text-subtitle-2 mb-1">포스터 내용:</div>
<div class="text-body-2">{{ posterData.content }}</div>
</div>
<div class="d-flex align-center mb-2">
<v-chip
:color="getStatusColor(posterData.status)"
size="small"
class="mr-2"
>
{{ getStatusText(posterData.status) }}
</v-chip>
<v-chip
color="primary"
size="small"
variant="outlined"
>
{{ posterData.contentType }}
</v-chip>
</div>
<div v-if="posterData.imageStyle" class="text-caption text-grey-600">
스타일: {{ posterData.imageStyle }}
</div>
</v-card-text>
</v-card>
<!-- 포스터 사이즈 옵션 -->
<v-card v-if="posterData.posterSizes && Object.keys(posterData.posterSizes).length > 0" variant="outlined">
<v-card-title class="text-subtitle-1">
<v-icon class="mr-2">mdi-resize</v-icon>
다양한 사이즈
</v-card-title>
<v-card-text>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="(url, size) in posterData.posterSizes"
:key="size"
@click="viewPosterSize(size, url)"
class="cursor-pointer"
variant="outlined"
>
{{ size }}
</v-chip>
</div>
</v-card-text>
</v-card>
<!-- 원본 이미지들 -->
<div v-if="posterData.originalImages && posterData.originalImages.length > 0" class="mt-4">
<div class="text-subtitle-2 mb-2">사용된 원본 이미지:</div>
<v-row>
<v-col
v-for="(image, index) in posterData.originalImages"
:key="index"
cols="6"
sm="4"
>
<v-img
:src="image"
:alt="`원본 이미지 ${index + 1}`"
cover
height="80"
class="rounded"
/>
</v-col>
</v-row>
</div>
</div>
<!-- 포스터 사이즈 보기 다이얼로그 -->
<v-dialog v-model="showSizeDialog" max-width="600">
<v-card>
<v-card-title>
포스터 사이즈: {{ selectedSize }}
</v-card-title>
<v-card-text class="text-center">
<v-img
:src="selectedSizeUrl"
:alt="`포스터 ${selectedSize}`"
contain
max-height="400"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
prepend-icon="mdi-download"
@click="downloadSelectedSize"
>
다운로드
</v-btn>
<v-btn @click="showSizeDialog = false">
닫기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
/**
* 포스터 미리보기 컴포넌트
* - 생성된 포스터 표시
* - 다운로드 공유 기능
* - 다양한 사이즈 보기
*/
// Props
const props = defineProps({
posterData: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
}
})
//
const showSizeDialog = ref(false)
const selectedSize = ref('')
const selectedSizeUrl = ref('')
/**
* 상태 색상 반환
*/
const getStatusColor = (status) => {
const colors = {
'DRAFT': 'orange',
'PUBLISHED': 'success',
'ARCHIVED': 'grey'
}
return colors[status] || 'primary'
}
/**
* 상태 텍스트 반환
*/
const getStatusText = (status) => {
const texts = {
'DRAFT': '임시저장',
'PUBLISHED': '발행',
'ARCHIVED': '보관'
}
return texts[status] || status
}
/**
* 포스터 다운로드
*/
const downloadPoster = () => {
if (!props.posterData?.posterImage) return
const link = document.createElement('a')
link.href = props.posterData.posterImage
link.download = `${props.posterData.title || '포스터'}.jpg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* 포스터 공유
*/
const sharePoster = async () => {
if (!props.posterData?.posterImage) return
if (navigator.share) {
try {
await navigator.share({
title: props.posterData.title,
text: '생성된 홍보 포스터를 확인해보세요!',
url: props.posterData.posterImage
})
} catch (error) {
console.log('공유 취소됨')
}
} else {
// URL
try {
await navigator.clipboard.writeText(props.posterData.posterImage)
// ( )
} catch (error) {
console.error('클립보드 복사 실패:', error)
}
}
}
/**
* 특정 사이즈 포스터 보기
*/
const viewPosterSize = (size, url) => {
selectedSize.value = size
selectedSizeUrl.value = url
showSizeDialog.value = true
}
/**
* 선택된 사이즈 포스터 다운로드
*/
const downloadSelectedSize = () => {
const link = document.createElement('a')
link.href = selectedSizeUrl.value
link.download = `${props.posterData.title || '포스터'}_${selectedSize.value}.jpg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
</script>
<style scoped>
.poster-preview {
min-height: 400px;
}
.poster-image-container {
position: relative;
}
.image-actions {
display: flex;
justify-content: center;
}
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@ -1,4 +1,5 @@
//* src/services/api.js - 수정된 API URL 설정
//* src/services/api.js - 수정된 버전 (createImageApiInstance 함수 추가)
import axios from 'axios'
// 런타임 환경 설정에서 API URL 가져오기
@ -6,15 +7,14 @@ const getApiUrls = () => {
const config = window.__runtime_config__ || {}
return {
GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3',
AUTH_URL: config.AUTH_URL || 'http://smarketing.20.249.184.228.nip.io/api/auth',
MEMBER_URL: config.MEMBER_URL || 'http://smarketing.20.249.184.228.nip.io/api/member',
STORE_URL: config.STORE_URL || 'http://smarketing.20.249.184.228.nip.io/api/store',
CONTENT_URL: config.CONTENT_URL || 'http://smarketing.20.249.184.228.nip.io/api/content',
MENU_URL: config.MENU_URL || 'http://smarketing.20.249.184.228.nip.io/api/menu',
// ⚠️ 수정: 매출 API는 store 서비스 (포트 8082)
SALES_URL: config.SALES_URL || 'http://smarketing.20.249.184.228.nip.io/api/sales',
// ⚠️ 수정: 추천 API는 ai-recommend 서비스 (포트 8084)
RECOMMEND_URL: config.RECOMMEND_URL || 'http://smarketing.20.249.184.228.nip.io/api/recommendations'
AUTH_URL: config.AUTH_URL || 'http://localhost:8081/api/auth',
MEMBER_URL: config.MEMBER_URL || 'http://localhost:8081/api/member',
STORE_URL: config.STORE_URL || 'http://localhost:8082/api/store',
CONTENT_URL: config.CONTENT_URL || 'http://localhost:8083/api/content',
MENU_URL: config.MENU_URL || 'http://localhost:8082/api/menu',
SALES_URL: config.SALES_URL || 'http://localhost:8082/api/sales',
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendations',
IMAGE_URL: config.IMAGE_URL || 'http://localhost:8082/api/images'
}
}
@ -37,7 +37,6 @@ const createApiInstance = (baseURL) => {
config.headers.Authorization = `Bearer ${token}`
}
// ⚠️ 추가: 요청 로깅 (개발 환경에서만)
if (import.meta.env.DEV) {
console.log(`🌐 [API_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
}
@ -52,14 +51,12 @@ const createApiInstance = (baseURL) => {
// 응답 인터셉터 - 토큰 갱신 및 에러 처리
instance.interceptors.response.use(
(response) => {
// ⚠️ 추가: 응답 로깅 (개발 환경에서만)
if (import.meta.env.DEV) {
console.log(`✅ [API_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
}
return response
},
async (error) => {
// ⚠️ 추가: 에러 로깅 (개발 환경에서만)
if (import.meta.env.DEV) {
console.error(`❌ [API_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
}
@ -102,10 +99,155 @@ const createApiInstance = (baseURL) => {
return instance
}
// ✅ 이미지 업로드 전용 API 인스턴스 생성 함수 추가
const createImageApiInstance = (baseURL) => {
const instance = axios.create({
baseURL,
timeout: 60000, // 이미지 업로드는 시간이 더 걸릴 수 있음
headers: {
Accept: 'application/json',
},
})
// 요청 인터셉터 - JWT 토큰 자동 추가
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
if (import.meta.env.DEV) {
console.log(`🌐 [IMG_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
console.log('FormData 포함:', config.data instanceof FormData)
}
return config
},
(error) => {
return Promise.reject(error)
},
)
// 응답 인터셉터
instance.interceptors.response.use(
(response) => {
if (import.meta.env.DEV) {
console.log(`✅ [IMG_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
}
return response
},
async (error) => {
if (import.meta.env.DEV) {
console.error(`❌ [IMG_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
}
// 토큰 갱신 로직은 기존과 동일
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem('refreshToken')
if (refreshToken) {
const refreshResponse = await axios.post(`${getApiUrls().AUTH_URL}/refresh`, {
refreshToken,
})
const { accessToken, refreshToken: newRefreshToken } = refreshResponse.data.data
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', newRefreshToken)
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return instance(originalRequest)
}
} catch (refreshError) {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
}
return Promise.reject(error)
},
)
return instance
}
// ✅ 메뉴 이미지 업로드 전용 API 인스턴스 생성 함수 추가
const createMenuImageApiInstance = (baseURL) => {
const instance = axios.create({
baseURL,
timeout: 60000, // 이미지 업로드는 시간이 더 걸릴 수 있음
headers: {
Accept: 'application/json',
},
})
// 요청 인터셉터 - JWT 토큰 자동 추가
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
if (import.meta.env.DEV) {
console.log(`🌐 [MENU_IMG_REQ] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`)
console.log('FormData 포함:', config.data instanceof FormData)
}
return config
},
(error) => {
return Promise.reject(error)
},
)
// 응답 인터셉터
instance.interceptors.response.use(
(response) => {
if (import.meta.env.DEV) {
console.log(`✅ [MENU_IMG_RES] ${response.status} ${response.config?.method?.toUpperCase()} ${response.config?.url}`)
}
return response
},
async (error) => {
if (import.meta.env.DEV) {
console.error(`❌ [MENU_IMG_ERR] ${error.response?.status || 'Network'} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, error.response?.data)
}
// 토큰 갱신 로직은 기존과 동일
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem('refreshToken')
if (refreshToken) {
const refreshResponse = await axios.post(`${getApiUrls().AUTH_URL}/refresh`, {
refreshToken,
})
const { accessToken, refreshToken: newRefreshToken } = refreshResponse.data.data
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', newRefreshToken)
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return instance(originalRequest)
}
} catch (refreshError) {
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
localStorage.removeItem('userInfo')
window.location.href = '/login'
}
}
return Promise.reject(error)
},
)
return instance
}
// API 인스턴스들 생성
const apiUrls = getApiUrls()
// ⚠️ 추가: API URL 확인 로깅 (개발 환경에서만)
if (import.meta.env.DEV) {
console.log('🔧 [API_CONFIG] API URLs 설정:', apiUrls)
}
@ -115,8 +257,11 @@ export const authApi = createApiInstance(apiUrls.AUTH_URL)
export const storeApi = createApiInstance(apiUrls.STORE_URL)
export const contentApi = createApiInstance(apiUrls.CONTENT_URL)
export const menuApi = createApiInstance(apiUrls.MENU_URL)
export const menuImageApi = createMenuImageApiInstance(apiUrls.MENU_URL) // ✅ 추가
export const salesApi = createApiInstance(apiUrls.SALES_URL)
export const recommendApi = createApiInstance(apiUrls.RECOMMEND_URL)
export const imageApi = createApiInstance(apiUrls.IMAGE_URL)
export const apiWithImage = imageApi // 별칭 (기존 코드 호환성)
// 기본 API 인스턴스 (Gateway URL 사용)
export const api = createApiInstance(apiUrls.GATEWAY_URL)
@ -185,7 +330,7 @@ export const formatSuccessResponse = (data, message = '요청이 성공적으로
}
}
// ⚠️ 추가: API 상태 확인 함수
// API 상태 확인 함수
export const checkApiHealth = async () => {
const results = {}
@ -214,11 +359,14 @@ export const checkApiHealth = async () => {
return results
}
// ⚠️ 추가: 개발 환경에서 전역 노출
// 개발 환경에서 전역 노출
if (import.meta.env.DEV) {
window.__api_debug__ = {
urls: apiUrls,
instances: { memberApi, authApi, storeApi, contentApi, menuApi, salesApi, recommendApi },
instances: {
memberApi, authApi, storeApi, contentApi, menuApi, menuImageApi,
salesApi, recommendApi, imageApi
},
checkHealth: checkApiHealth
}
console.log('🔧 [DEBUG] API 인스턴스가 window.__api_debug__에 노출됨')

File diff suppressed because it is too large Load Diff

View File

@ -140,8 +140,10 @@ class StoreService {
businessType: storeData.businessType,
address: storeData.address,
phoneNumber: storeData.phoneNumber,
businessHours: storeData.businessHours,
closedDays: storeData.closedDays,
// ✅ 수정: businessHours 필드 처리
businessHours: storeData.businessHours || `${storeData.openTime || '09:00'}-${storeData.closeTime || '21:00'}`,
// ✅ 수정: closedDays 필드 처리
closedDays: storeData.closedDays || storeData.holidays || '',
seatCount: parseInt(storeData.seatCount) || 0,
instaAccounts: storeData.instaAccounts || '',
blogAccounts: storeData.blogAccounts || '',
@ -150,9 +152,9 @@ class StoreService {
console.log('백엔드 전송 데이터:', requestData)
// PUT 요청 (storeId는 JWT에서 추출하므로 URL에 포함하지 않음)
const response = await storeApi.put('/', requestData)
// ✅ 핵심 수정: 슬래시 제거하고 빈 문자열 사용
console.log('API 호출 경로: PUT /api/store (baseURL + 빈 문자열)')
const response = await storeApi.put('', requestData)
console.log('매장 정보 수정 API 응답:', response.data)
if (response.data && (response.data.status === 200 || response.data.success !== false)) {

File diff suppressed because it is too large Load Diff

View File

@ -79,80 +79,88 @@ export const useStoreStore = defineStore('store', {
}
},
/**
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ID 필드 보장
*/
async fetchMenus() {
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
// src/store/index.js에서 fetchMenus 부분만 수정
try {
// 매장 정보에서 storeId 가져오기
const storeId = this.storeInfo?.storeId
if (!storeId) {
console.warn('매장 ID가 없습니다. 매장 정보를 먼저 조회해주세요.')
return { success: false, message: '매장 정보가 필요합니다', data: [] }
/**
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - 이미지 필드 매핑 수정
*/
async fetchMenus() {
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
try {
// 매장 정보에서 storeId 가져오기
const storeId = this.storeInfo?.storeId
if (!storeId) {
console.warn('매장 ID가 없습니다. 매장 정보를 먼저 조회해주세요.')
return { success: false, message: '매장 정보가 필요합니다', data: [] }
}
// 메뉴 서비스 임포트
const { menuService } = await import('@/services/menu')
console.log('메뉴 목록 API 호출, 매장 ID:', storeId)
const result = await menuService.getMenus(storeId)
console.log('=== Store 스토어: 메뉴 API 응답 분석 ===')
console.log('Result:', result)
console.log('Result.success:', result.success)
console.log('Result.data:', result.data)
console.log('Result.message:', result.message)
if (result.success && result.data) {
// ✅ 백엔드 MenuResponse의 필드명에 맞게 매핑 수정
const menusWithId = (result.data || []).map(menu => {
// ID 필드가 확실히 있도록 보장
const menuId = menu.menuId || menu.id
if (!menuId) {
console.warn('⚠️ 메뉴 ID가 없는 항목 발견:', menu)
}
// 메뉴 서비스 임포트
const { menuService } = await import('@/services/menu')
console.log('메뉴 원본 데이터:', menu) // 디버깅용
console.log('메뉴 목록 API 호출, 매장 ID:', storeId)
const result = await menuService.getMenus(storeId)
console.log('=== Store 스토어: 메뉴 API 응답 분석 ===')
console.log('Result:', result)
console.log('Result.success:', result.success)
console.log('Result.data:', result.data)
console.log('Result.message:', result.message)
if (result.success && result.data) {
// ✅ 메뉴 데이터 ID 필드 보장 처리
const menusWithId = (result.data || []).map(menu => {
// ID 필드가 확실히 있도록 보장
const menuId = menu.menuId || menu.id
if (!menuId) {
console.warn('⚠️ 메뉴 ID가 없는 항목 발견:', menu)
}
return {
...menu,
id: menuId, // ✅ id 필드 확실히 설정
menuId: menuId, // ✅ menuId 필드도 설정
// 기타 필드들 보장
menuName: menu.menuName || menu.name || '이름 없음',
category: menu.category || '기타',
price: menu.price || 0,
description: menu.description || '',
available: menu.available !== undefined ? menu.available : true,
recommended: menu.recommended !== undefined ? menu.recommended : false,
imageUrl: menu.imageUrl || '/images/menu-placeholder.png'
}
})
// 메뉴 목록이 있는 경우
console.log('✅ 메뉴 목록 설정 (ID 보장됨):', menusWithId)
this.menus = menusWithId
return { success: true, data: menusWithId }
} else {
// 메뉴가 없거나 조회 실패한 경우
console.log('⚠️ 메뉴 목록 없음 또는 조회 실패')
this.menus = []
if (result.message === '등록된 메뉴가 없습니다') {
return { success: false, message: '등록된 메뉴가 없습니다', data: [] }
} else {
return { success: false, message: result.message || '메뉴 목록 조회에 실패했습니다', data: [] }
}
return {
...menu,
id: menuId, // ✅ id 필드 확실히 설정
menuId: menuId, // ✅ menuId 필드도 설정
// 기타 필드들 보장
menuName: menu.menuName || menu.name || '이름 없음',
category: menu.category || '기타',
price: menu.price || 0,
description: menu.description || '',
available: menu.available !== undefined ? menu.available : true,
recommended: menu.recommended !== undefined ? menu.recommended : false,
// ✅ 이미지 필드 수정: 백엔드는 'image' 필드 사용
imageUrl: menu.image || menu.imageUrl || '/images/menu-placeholder.png',
image: menu.image || menu.imageUrl, // 백엔드 호환성
createdAt: menu.createdAt,
updatedAt: menu.updatedAt
}
} catch (error) {
console.error('=== Store 스토어: 메뉴 목록 조회 실패 ===')
console.error('Error:', error)
})
this.menus = []
return { success: false, message: error.message || '메뉴 목록을 불러오는데 실패했습니다', data: [] }
// 메뉴 목록이 있는 경우
console.log('✅ 메뉴 목록 설정 (이미지 필드 매핑 완료):', menusWithId)
this.menus = menusWithId
return { success: true, data: menusWithId }
} else {
// 메뉴가 없거나 조회 실패한 경우
console.log('⚠️ 메뉴 목록 없음 또는 조회 실패')
this.menus = []
if (result.message === '등록된 메뉴가 없습니다') {
return { success: false, message: '등록된 메뉴가 없습니다', data: [] }
} else {
return { success: false, message: result.message || '메뉴 목록 조회에 실패했습니다', data: [] }
}
},
}
} catch (error) {
console.error('=== Store 스토어: 메뉴 목록 조회 실패 ===')
console.error('Error:', error)
this.menus = []
return { success: false, message: error.message || '메뉴 목록을 불러오는데 실패했습니다', data: [] }
}
},
/**
* 매장 등록

281
src/store/poster.js Normal file
View File

@ -0,0 +1,281 @@
//* src/store/poster.js - 이미지 처리 강화 및 디버깅 추가
import { defineStore } from 'pinia'
import { contentService } from '@/services/content'
import { useAuthStore } from '@/store/auth'
export const usePosterStore = defineStore('poster', {
state: () => ({
posters: [],
currentPoster: null,
loading: false,
error: null
}),
getters: {
getPosterById: (state) => (id) => {
return state.posters.find(poster => poster.id === id)
},
recentPosters: (state) => {
return state.posters
.slice()
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10)
}
},
actions: {
/**
* 포스터 생성 - 이미지 처리 강화
*/
async generatePoster(posterData) {
this.loading = true
this.error = null
try {
console.log('🎯 [POSTER_STORE] 포스터 생성 요청 받음:', posterData)
console.log('📁 [POSTER_STORE] 이미지 상태 확인:', {
hasImages: !!posterData.images,
isArray: Array.isArray(posterData.images),
imageCount: posterData.images?.length || 0,
imageDetails: posterData.images?.map((img, idx) => ({
index: idx,
type: typeof img,
length: img?.length,
isBase64: typeof img === 'string' && img.startsWith('data:image/'),
preview: typeof img === 'string' ? img.substring(0, 30) + '...' : 'not string'
})) || []
})
// ✅ 이미지 전처리 및 검증
let processedImages = []
if (posterData.images && Array.isArray(posterData.images) && posterData.images.length > 0) {
console.log('📁 [POSTER_STORE] 이미지 전처리 시작...')
processedImages = posterData.images
.filter((img, index) => {
const isValid = img &&
typeof img === 'string' &&
img.length > 100 &&
(img.startsWith('data:image/') || img.startsWith('http'))
console.log(`📁 [POSTER_STORE] 이미지 ${index + 1} 검증:`, {
isValid,
type: typeof img,
length: img?.length,
format: img?.substring(0, 20) || 'unknown'
})
return isValid
})
console.log('📁 [POSTER_STORE] 전처리 결과:', {
원본: posterData.images.length,
유효: processedImages.length,
제거됨: posterData.images.length - processedImages.length
})
if (processedImages.length === 0) {
throw new Error('유효한 이미지가 없습니다. 이미지를 다시 업로드해 주세요.')
}
} else {
console.warn('⚠️ [POSTER_STORE] 이미지가 없습니다!')
throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.')
}
// ✅ API 요청에 맞는 형태로 데이터 변환 - 검증된 이미지 사용
const requestData = {
storeId: posterData.storeId,
title: posterData.title,
targetAudience: posterData.targetAudience,
promotionStartDate: posterData.promotionStartDate,
promotionEndDate: posterData.promotionEndDate,
images: processedImages, // 검증된 이미지만 사용
targetAge: posterData.targetAge
}
// 선택적 필드들
if (posterData.eventName) requestData.eventName = posterData.eventName
if (posterData.imageStyle) requestData.imageStyle = posterData.imageStyle
if (posterData.promotionType) requestData.promotionType = posterData.promotionType
if (posterData.emotionIntensity) requestData.emotionIntensity = posterData.emotionIntensity
if (posterData.category) requestData.category = posterData.category
if (posterData.requirement) requestData.requirement = posterData.requirement
if (posterData.toneAndManner) requestData.toneAndManner = posterData.toneAndManner
if (posterData.startDate) requestData.startDate = posterData.startDate
if (posterData.endDate) requestData.endDate = posterData.endDate
if (posterData.photoStyle) requestData.photoStyle = posterData.photoStyle
console.log('📤 [POSTER_STORE] 최종 요청 데이터:', {
...requestData,
images: `${requestData.images.length}개 이미지 (${Math.round(JSON.stringify(requestData.images).length / 1024)}KB)`
})
// ✅ 마지막 검증
if (!requestData.title) {
throw new Error('제목은 필수입니다.')
}
if (!requestData.targetAudience) {
throw new Error('홍보 대상은 필수입니다.')
}
if (!requestData.images || requestData.images.length === 0) {
throw new Error('이미지는 필수입니다.')
}
console.log('🚀 [POSTER_STORE] contentService.generatePoster 호출...')
const result = await contentService.generatePoster(requestData)
if (result.success) {
console.log('✅ [POSTER_STORE] 포스터 생성 성공:', result.data)
this.currentPoster = result.data
return result
} else {
console.error('❌ [POSTER_STORE] 포스터 생성 실패:', result.message)
this.error = result.message
return result
}
} catch (error) {
console.error('❌ [POSTER_STORE] 포스터 생성 예외:', error)
// 상세한 오류 정보 로깅
if (error.response) {
console.error('❌ [POSTER_STORE] HTTP 오류:', {
status: error.response.status,
statusText: error.response.statusText,
data: error.response.data
})
}
this.error = error.message || '포스터 생성 중 오류가 발생했습니다.'
return {
success: false,
message: this.error
}
} finally {
this.loading = false
}
},
/**
* 포스터 저장
*/
async savePoster(saveData) {
this.loading = true
this.error = null
try {
console.log('💾 [POSTER_STORE] 포스터 저장 요청:', saveData)
const result = await contentService.savePoster(saveData)
if (result.success) {
console.log('✅ [POSTER_STORE] 포스터 저장 성공')
// 저장된 포스터를 목록에 추가
if (result.data) {
this.posters.unshift(result.data)
}
return result
} else {
console.error('❌ [POSTER_STORE] 포스터 저장 실패:', result.message)
this.error = result.message
return result
}
} catch (error) {
console.error('❌ [POSTER_STORE] 포스터 저장 예외:', error)
this.error = error.message || '포스터 저장 중 오류가 발생했습니다.'
return {
success: false,
message: this.error
}
} finally {
this.loading = false
}
},
/**
* 포스터 목록 조회
*/
async fetchPosters() {
this.loading = true
this.error = null
try {
const result = await contentService.getContents({
contentType: 'poster',
sortBy: 'latest'
})
if (result.success) {
this.posters = result.data || []
return result
} else {
this.error = result.message
return result
}
} catch (error) {
console.error('❌ [POSTER_STORE] 포스터 목록 조회 예외:', error)
this.error = error.message || '포스터 목록 조회 중 오류가 발생했습니다.'
return {
success: false,
message: this.error
}
} finally {
this.loading = false
}
},
/**
* 포스터 삭제
*/
async deletePoster(posterId) {
this.loading = true
this.error = null
try {
const result = await contentService.deleteContent(posterId)
if (result.success) {
// 목록에서 삭제
this.posters = this.posters.filter(poster => poster.id !== posterId)
// 현재 포스터가 삭제된 포스터라면 초기화
if (this.currentPoster?.id === posterId) {
this.currentPoster = null
}
return result
} else {
this.error = result.message
return result
}
} catch (error) {
console.error('❌ [POSTER_STORE] 포스터 삭제 예외:', error)
this.error = error.message || '포스터 삭제 중 오류가 발생했습니다.'
return {
success: false,
message: this.error
}
} finally {
this.loading = false
}
},
/**
* 에러 상태 초기화
*/
clearError() {
this.error = null
},
/**
* 현재 포스터 설정
*/
setCurrentPoster(poster) {
this.currentPoster = poster
}
}
})

View File

@ -1,533 +1,243 @@
//* src/services/store.js - 병합 충돌 해결된 매장 서비스
import { storeApi, menuApi, handleApiError, formatSuccessResponse } from './api.js'
//* src/store/content.js 수정 - 매장 정보 조회 후 콘텐츠 목록 조회
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import contentService from '@/services/content'
import { useAuthStore } from '@/store/auth'
export const useContentStore = defineStore('content', () => {
// 상태
const contentList = ref([])
const contents = ref([]) // ContentManagementView에서 사용하는 속성
const ongoingContents = ref([])
const selectedContent = ref(null)
const generatedContent = ref(null)
const isLoading = ref(false)
// computed 속성들
const contentCount = computed(() => contentList.value.length)
const ongoingContentCount = computed(() => ongoingContents.value.length)
// 콘텐츠 목록 로딩 (ContentManagementView에서 사용)
const loadContents = async (filters = {}) => {
console.log('=== 콘텐츠 목록 조회 시작 ===')
isLoading.value = true
/**
* 매장 관련 API 서비스
* 백엔드 Store Controller와 연동 (포트 8082)
*/
class StoreService {
/**
* 매장 등록 (STR-015: 매장 등록)
* @param {Object} storeData - 매장 정보
* @returns {Promise<Object>} 매장 등록 결과
*/
async registerStore(storeData) {
try {
console.log('=== 매장 등록 API 호출 ===')
console.log('요청 데이터:', storeData)
// 1단계: 매장 정보 조회하여 실제 storeId 가져오기
const userInfo = useAuthStore().user
let storeId = null
// 백엔드 StoreCreateRequest에 맞는 형태로 변환
const requestData = {
storeName: storeData.storeName,
businessType: storeData.businessType,
address: storeData.address,
phoneNumber: storeData.phoneNumber,
businessHours: storeData.businessHours,
closedDays: storeData.closedDays,
seatCount: parseInt(storeData.seatCount) || 0,
instaAccounts: storeData.instaAccounts || '',
blogAccounts: storeData.blogAccounts || '',
description: storeData.description || ''
}
console.log('=== 각 필드 상세 검증 ===')
console.log('storeName:', requestData.storeName, '(타입:', typeof requestData.storeName, ')')
console.log('businessType:', requestData.businessType, '(타입:', typeof requestData.businessType, ')')
console.log('address:', requestData.address, '(타입:', typeof requestData.address, ')')
console.log('seatCount:', requestData.seatCount, '(타입:', typeof requestData.seatCount, ')')
console.log('백엔드 전송 데이터:', requestData)
const response = await storeApi.post('/register', requestData)
console.log('매장 등록 API 응답:', response.data)
// 백엔드 응답 구조에 맞게 처리
if (response.data && (response.data.status === 200 || response.data.success !== false)) {
return {
success: true,
message: response.data.message || '매장이 등록되었습니다.',
data: response.data.data
}
} else {
throw new Error(response.data.message || '매장 등록에 실패했습니다.')
}
} catch (error) {
console.error('매장 등록 실패:', error)
if (error.response) {
console.error('응답 상태:', error.response.status)
console.error('응답 데이터:', error.response.data)
}
return handleApiError(error)
}
}
/**
* 매장 정보 조회 (STR-005: 매장 정보 관리)
* @returns {Promise<Object>} 매장 정보
*/
async getStore() {
try {
console.log('=== 매장 정보 조회 API 호출 ===')
// URL 슬래시 문제 해결: 빈 문자열로 호출하여 '/api/store'가 되도록 함
const response = await storeApi.get('')
console.log('매장 정보 조회 API 응답:', response.data)
// 백엔드 응답 구조 수정: 디버깅 결과에 맞게 처리
if (response.data && response.data.status === 200 && response.data.data) {
console.log('✅ 매장 정보 조회 성공:', response.data.data)
return {
success: true,
message: response.data.message || '매장 정보를 조회했습니다.',
data: response.data.data
}
} else if (response.data && response.data.status === 404) {
// 매장이 없는 경우
console.log('⚠️ 등록된 매장이 없음')
return {
success: false,
message: '등록된 매장이 없습니다',
data: null
}
} else {
console.warn('예상치 못한 응답 구조:', response.data)
throw new Error(response.data.message || '매장 정보를 찾을 수 없습니다.')
}
} catch (error) {
console.error('매장 정보 조회 실패:', error)
// 404 오류 처리 (매장이 없음)
if (error.response?.status === 404) {
return {
success: false,
message: '등록된 매장이 없습니다',
data: null
}
}
// 500 오류 처리 (서버 내부 오류)
if (error.response?.status === 500) {
console.error('서버 내부 오류 - 백엔드 로그 확인 필요:', error.response?.data)
return {
success: false,
message: '서버 오류가 발생했습니다. 관리자에게 문의하세요.',
data: null
}
}
return handleApiError(error)
}
}
/**
* 매장 정보 수정 (STR-010: 매장 수정)
* @param {number} storeId - 매장 ID (현재는 사용하지 않음 - JWT에서 사용자 확인)
* @param {Object} storeData - 수정할 매장 정보
* @returns {Promise<Object>} 매장 수정 결과
*/
async updateStore(storeId, storeData) {
try {
console.log('=== 매장 정보 수정 API 호출 ===')
console.log('요청 데이터:', storeData)
// 백엔드 StoreUpdateRequest에 맞는 형태로 변환
const requestData = {
storeName: storeData.storeName,
businessType: storeData.businessType,
address: storeData.address,
phoneNumber: storeData.phoneNumber,
businessHours: storeData.businessHours,
closedDays: storeData.closedDays,
seatCount: parseInt(storeData.seatCount) || 0,
instaAccounts: storeData.instaAccounts || '',
blogAccounts: storeData.blogAccounts || '',
description: storeData.description || ''
}
console.log('백엔드 전송 데이터:', requestData)
// PUT 요청 (storeId는 JWT에서 추출하므로 URL에 포함하지 않음)
const response = await storeApi.put('/', requestData)
console.log('매장 정보 수정 API 응답:', response.data)
if (response.data && (response.data.status === 200 || response.data.success !== false)) {
return {
success: true,
message: response.data.message || '매장 정보가 수정되었습니다.',
data: response.data.data
}
} else {
throw new Error(response.data.message || '매장 정보 수정에 실패했습니다.')
}
} catch (error) {
console.error('매장 정보 수정 실패:', error)
return handleApiError(error)
}
}
/**
* 매출 정보 조회 (STR-020: 대시보드)
* @param {string} period - 조회 기간 (today, week, month, year)
* @returns {Promise<Object>} 매출 정보
*/
async getSales(period = 'today') {
try {
console.log('=== 매출 정보 조회 API 호출 ===')
console.log('조회 기간:', period)
// 현재는 목업 데이터 반환 (추후 실제 API 연동 시 수정)
const mockSalesData = {
todaySales: 150000,
yesterdaySales: 120000,
changeRate: 25.0,
monthlyTarget: 3000000,
achievementRate: 45.2,
yearSales: this.generateMockYearSales()
}
// 매출 트렌드 분석 추가
if (mockSalesData.yearSales && mockSalesData.yearSales.length > 0) {
mockSalesData.trendAnalysis = this.analyzeSalesTrend(mockSalesData.yearSales)
mockSalesData.chartData = this.prepareChartData(mockSalesData.yearSales)
}
return formatSuccessResponse(mockSalesData, '매출 정보를 조회했습니다.')
} catch (error) {
console.error('매출 정보 조회 실패:', error)
return handleApiError(error)
}
}
/**
* 메뉴 목록 조회 (최종 통합 버전 - 모든 충돌 해결)
* @param {number} storeId - 매장 ID (옵션, 없으면 목업 데이터 반환)
* @returns {Promise<Object>} 메뉴 목록
*/
async getMenus(storeId) {
try {
console.log('=== 메뉴 목록 조회 API 호출 ===')
console.log('매장 ID:', storeId)
// storeId가 없으면 목업 데이터 반환 (개발 중)
if (!storeId) {
console.warn('매장 ID가 없어서 목업 데이터 반환')
const mockMenus = [
{
menuId: 1, // id 대신 menuId 사용
id: 1, // 호환성을 위해
name: '아메리카노',
menuName: '아메리카노', // 백엔드 형식
price: 4000,
category: '커피',
description: '진한 풍미의 아메리카노',
imageUrl: '/images/americano.jpg',
isAvailable: true,
available: true // 백엔드 형식
},
{
menuId: 2,
id: 2,
name: '카페라떼',
menuName: '카페라떼',
price: 4500,
category: '커피',
description: '부드러운 우유가 들어간 라떼',
imageUrl: '/images/latte.jpg',
isAvailable: true,
available: true
}
]
return formatSuccessResponse(mockMenus, '목업 메뉴 목록을 조회했습니다.')
}
// 실제 백엔드 API 호출 시도
try {
// GET /api/menu?storeId={storeId}
const response = await menuApi.get('', {
params: { storeId }
// 매장 정보 API 호출
const storeApiUrl = (window.__runtime_config__ && window.__runtime_config__.STORE_URL)
? window.__runtime_config__.STORE_URL
: 'http://localhost:8082/api/store'
console.log('매장 API URL:', storeApiUrl)
const token = localStorage.getItem('accessToken') || localStorage.getItem('auth_token') || localStorage.getItem('token')
const storeResponse = await fetch(`${storeApiUrl}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
console.log('메뉴 목록 조회 API 응답:', response.data)
if (response.data && response.data.status === 200) {
// 백엔드에서 받은 메뉴 데이터를 프론트엔드 형식으로 변환
const menus = response.data.data.map(menu => ({
menuId: menu.menuId,
id: menu.menuId, // 호환성을 위해
storeId: menu.storeId,
menuName: menu.menuName,
name: menu.menuName, // 호환성을 위해
category: menu.category,
price: menu.price,
description: menu.description,
available: menu.available !== undefined ? menu.available : true,
isAvailable: menu.available !== undefined ? menu.available : true, // 호환성
imageUrl: menu.imageUrl || '/images/menu-placeholder.png',
createdAt: menu.createdAt,
updatedAt: menu.updatedAt
}))
return formatSuccessResponse(menus, '메뉴 목록을 조회했습니다.')
if (storeResponse.ok) {
const storeData = await storeResponse.json()
storeId = storeData.data?.storeId
console.log('✅ 매장 정보 조회 성공, storeId:', storeId)
} else {
throw new Error(response.data.message || '메뉴 목록을 찾을 수 없습니다.')
throw new Error(`매장 정보 조회 실패: ${storeResponse.status}`)
}
} catch (apiError) {
console.error('백엔드 API 호출 실패:', apiError)
} catch (error) {
console.error('❌ 매장 정보 조회 실패:', error)
throw new Error('매장 정보를 조회할 수 없습니다.')
}
// 백엔드 미구현이나 네트워크 오류 시 목업 데이터 반환
if (apiError.response?.status === 404 ||
apiError.code === 'ECONNREFUSED' ||
apiError.message.includes('Network Error')) {
console.warn('백엔드 미구현 - 목업 데이터 반환')
if (!storeId) {
throw new Error('매장 ID를 찾을 수 없습니다.')
}
const mockMenus = [
{
menuId: 1,
id: 1,
storeId: storeId,
name: '아메리카노',
menuName: '아메리카노',
price: 4000,
category: '커피',
description: '진한 풍미의 아메리카노',
imageUrl: '/images/americano.jpg',
isAvailable: true,
available: true
},
{
menuId: 2,
id: 2,
storeId: storeId,
name: '카페라떼',
menuName: '카페라떼',
price: 4500,
category: '커피',
description: '부드러운 우유가 들어간 라떼',
imageUrl: '/images/latte.jpg',
isAvailable: true,
available: true
}
]
console.log('사용자 정보:', userInfo)
console.log('조회된 storeId:', storeId)
return formatSuccessResponse(mockMenus, '목업 메뉴 목록을 조회했습니다. (백엔드 미구현)')
}
// 2단계: 조회된 storeId로 콘텐츠 목록 조회
const apiFilters = {
platform: filters.platform || null, // 전체, INSTAGRAM, NAVER_BLOG, POSTER 등
storeId: storeId,
sortBy: filters.sortBy || 'latest'
// contentType, period는 백엔드에서 사용하지 않으므로 제외
}
throw apiError
console.log('API 요청 필터:', apiFilters)
const result = await contentService.getContents(apiFilters)
console.log('🔍 contentService.getContents 결과:', result)
console.log('🔍 result.success:', result.success)
console.log('🔍 result.data:', result.data)
console.log('🔍 result.data 타입:', typeof result.data)
console.log('🔍 result.data 길이:', result.data?.length)
if (result.success) {
contents.value = result.data || []
contentList.value = result.data || []
console.log('✅ 콘텐츠 로딩 성공:', contents.value.length, '개')
return { success: true }
} else {
console.error('❌ 콘텐츠 로딩 실패:', result.error)
contents.value = []
contentList.value = []
return { success: false, error: result.error }
}
} catch (error) {
console.error('메뉴 목록 조회 실패:', error)
return handleApiError(error)
console.error('❌ 콘텐츠 로딩 실패:', error)
contents.value = []
contentList.value = []
return { success: false, error: error.message || '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
/**
* 메뉴 목록 조회 별칭 (fetchMenus)
* @param {number} storeId - 매장 ID
* @returns {Promise<Object>} 메뉴 목록
*/
async fetchMenus(storeId) {
return await this.getMenus(storeId)
}
// AI 콘텐츠 생성
const generateContent = async (type, formData) => {
isLoading.value = true
/**
* 목업 메뉴 데이터 생성
* @param {number} storeId - 매장 ID (옵션)
* @returns {Array} 목업 메뉴 배열
*/
getMockMenus(storeId = null) {
return [
{
menuId: 1,
id: 1,
storeId: storeId,
name: '아메리카노',
menuName: '아메리카노',
price: 4000,
category: '커피',
description: '진한 풍미의 아메리카노',
imageUrl: '/images/americano.jpg',
isAvailable: true,
available: true
},
{
menuId: 2,
id: 2,
storeId: storeId,
name: '카페라떼',
menuName: '카페라떼',
price: 4500,
category: '커피',
description: '부드러운 우유가 들어간 라떼',
imageUrl: '/images/latte.jpg',
isAvailable: true,
available: true
},
{
menuId: 3,
id: 3,
storeId: storeId,
name: '에스프레소',
menuName: '에스프레소',
price: 3500,
category: '커피',
description: '진한 에스프레소 한 잔',
imageUrl: '/images/espresso.jpg',
isAvailable: true,
available: true
try {
let result
if (type === 'sns') {
result = await contentService.generateSnsContent(formData)
} else if (type === 'poster') {
result = await contentService.generatePoster(formData)
}
]
}
/**
* 목업 연간 매출 데이터 생성
* @returns {Array} 목업 매출 데이터
*/
generateMockYearSales() {
const salesData = []
const today = new Date()
// 최근 90일 데이터 생성
for (let i = 89; i >= 0; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
// 랜덤하지만 현실적인 매출 패턴 생성
const baseAmount = 120000 + Math.random() * 80000 // 120,000 ~ 200,000
const weekendBonus = date.getDay() === 0 || date.getDay() === 6 ? 1.3 : 1.0
const monthlyTrend = 1 + (Math.sin(i / 30) * 0.2) // 월별 트렌드
salesData.push({
salesDate: date.toISOString().split('T')[0],
salesAmount: Math.round(baseAmount * weekendBonus * monthlyTrend)
})
}
return salesData
}
/**
* 매출 트렌드 분석 변곡점 계산
* @param {Array} yearSales - 연간 매출 데이터
* @returns {Object} 트렌드 분석 결과
*/
analyzeSalesTrend(yearSales) {
if (!yearSales || yearSales.length < 7) {
return {
inflectionPoints: [],
overallTrend: 'insufficient_data',
growthRate: 0
if (result.success) {
generatedContent.value = result.data
return { success: true, data: result.data }
} else {
return { success: false, error: result.message }
}
} catch (error) {
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
// 날짜순 정렬
const sortedData = [...yearSales].sort((a, b) =>
new Date(a.salesDate) - new Date(b.salesDate)
)
// 콘텐츠 저장
const saveContent = async (type, contentData) => {
isLoading.value = true
const inflectionPoints = []
const dailyData = sortedData.map(item => ({
date: item.salesDate,
amount: parseFloat(item.salesAmount) || 0
}))
// 7일 이동평균으로 변곡점 탐지
for (let i = 7; i < dailyData.length - 7; i++) {
const prevWeekAvg = this.calculateMovingAverage(dailyData, i - 7, 7)
const currentWeekAvg = this.calculateMovingAverage(dailyData, i, 7)
const nextWeekAvg = this.calculateMovingAverage(dailyData, i + 7, 7)
const trend1 = currentWeekAvg - prevWeekAvg
const trend2 = nextWeekAvg - currentWeekAvg
// 변곡점 조건: 트렌드 방향 변화 + 임계값 초과
if (Math.sign(trend1) !== Math.sign(trend2) &&
Math.abs(trend1) > 10000 && Math.abs(trend2) > 10000) {
inflectionPoints.push({
date: dailyData[i].date,
amount: dailyData[i].amount,
type: trend1 > 0 ? 'peak' : 'valley',
significance: Math.abs(trend1) + Math.abs(trend2)
})
try {
let result
if (type === 'sns') {
result = await contentService.saveSnsContent(contentData)
} else if (type === 'poster') {
result = await contentService.savePoster(contentData)
}
}
// 전체 트렌드 계산
const firstWeekAvg = this.calculateMovingAverage(dailyData, 0, 7)
const lastWeekAvg = this.calculateMovingAverage(dailyData, dailyData.length - 7, 7)
const growthRate = ((lastWeekAvg - firstWeekAvg) / firstWeekAvg) * 100
let overallTrend = 'stable'
if (Math.abs(growthRate) > 5) {
overallTrend = growthRate > 0 ? 'increasing' : 'decreasing'
}
console.log('변곡점 분석 결과:', { inflectionPoints, overallTrend, growthRate })
return {
inflectionPoints: inflectionPoints.slice(0, 5), // 상위 5개만
overallTrend,
growthRate: Math.round(growthRate * 10) / 10
if (result.success) {
// 콘텐츠 목록 새로고침
await loadContents()
return { success: true, message: '콘텐츠가 저장되었습니다.' }
} else {
return { success: false, error: result.message }
}
} catch (error) {
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
/**
* 이동평균 계산
* @param {Array} data - 데이터 배열
* @param {number} startIndex - 시작 인덱스
* @param {number} period - 기간
* @returns {number} 이동평균값
*/
calculateMovingAverage(data, startIndex, period) {
const slice = data.slice(startIndex, startIndex + period)
const sum = slice.reduce((acc, item) => acc + item.amount, 0)
return sum / slice.length
// fetchContentList - 기존 호환성 유지
const fetchContentList = async (filters = {}) => {
return await loadContents(filters)
}
/**
* 차트용 데이터 준비
* @param {Array} yearSales - 연간 매출 데이터
* @returns {Object} 차트 데이터
*/
prepareChartData(yearSales) {
if (!yearSales || yearSales.length === 0) {
return { labels: [], salesData: [], targetData: [] }
}
// 진행 중인 콘텐츠 조회
const fetchOngoingContents = async (period = 'month') => {
isLoading.value = true
// 최근 30일 데이터만 사용 (차트 표시용)
const sortedData = [...yearSales]
.sort((a, b) => new Date(a.salesDate) - new Date(b.salesDate))
.slice(-30)
try {
const result = await contentService.getOngoingContents(period)
const labels = sortedData.map(item => {
const date = new Date(item.salesDate)
return `${date.getMonth() + 1}/${date.getDate()}`
})
const salesData = sortedData.map(item => parseFloat(item.salesAmount) || 0)
// 목표 매출 라인 (평균의 110%)
const averageSales = salesData.reduce((a, b) => a + b, 0) / salesData.length
const targetData = salesData.map(() => Math.round(averageSales * 1.1))
return {
labels,
salesData,
targetData
if (result.success) {
ongoingContents.value = result.data
return { success: true }
} else {
return { success: false, error: result.message }
}
} catch (error) {
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
}
// 싱글톤 인스턴스 생성 및 export
export const storeService = new StoreService()
export default storeService
// 콘텐츠 수정
const updateContent = async (contentId, updateData) => {
isLoading.value = true
// 디버깅을 위한 전역 노출 (개발 환경에서만)
if (process.env.NODE_ENV === 'development') {
window.storeService = storeService
}
try {
const result = await contentService.updateContent(contentId, updateData)
if (result.success) {
await loadContents()
return { success: true, message: '콘텐츠가 수정되었습니다.' }
} else {
return { success: false, error: result.message }
}
} catch (error) {
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
// 콘텐츠 삭제
const deleteContent = async (contentId) => {
isLoading.value = true
try {
const result = await contentService.deleteContent(contentId)
if (result.success) {
await loadContents()
return { success: true, message: '콘텐츠가 삭제되었습니다.' }
} else {
return { success: false, error: result.message }
}
} catch (error) {
return { success: false, error: '네트워크 오류가 발생했습니다.' }
} finally {
isLoading.value = false
}
}
return {
// 상태
contentList,
contents, // ContentManagementView에서 사용
ongoingContents,
selectedContent,
generatedContent,
isLoading,
// 컴퓨티드
contentCount,
ongoingContentCount,
// 메서드
loadContents, // 새로 추가된 메서드
generateContent,
saveContent,
fetchContentList, // 기존 호환성 유지
fetchOngoingContents,
updateContent,
deleteContent
}
})

View File

@ -7,15 +7,7 @@
export const CONTENT_TYPES = {
SNS: 'sns',
POSTER: 'poster',
VIDEO: 'video',
BLOG: 'blog',
}
export const CONTENT_TYPE_LABELS = {
[CONTENT_TYPES.SNS]: 'SNS 게시물',
[CONTENT_TYPES.POSTER]: '홍보 포스터',
[CONTENT_TYPES.VIDEO]: '비디오',
[CONTENT_TYPES.BLOG]: '블로그 포스트',
BLOG: 'blog'
}
// 플랫폼
@ -23,191 +15,201 @@ export const PLATFORMS = {
INSTAGRAM: 'instagram',
NAVER_BLOG: 'naver_blog',
FACEBOOK: 'facebook',
TWITTER: 'twitter',
YOUTUBE: 'youtube',
KAKAO: 'kakao',
KAKAO_STORY: 'kakao_story'
}
// 플랫폼 라벨
export const PLATFORM_LABELS = {
[PLATFORMS.INSTAGRAM]: '인스타그램',
[PLATFORMS.NAVER_BLOG]: '네이버 블로그',
[PLATFORMS.FACEBOOK]: '페이스북',
[PLATFORMS.TWITTER]: '트위터',
[PLATFORMS.YOUTUBE]: '유튜브',
[PLATFORMS.KAKAO]: '카카오',
[PLATFORMS.KAKAO_STORY]: '카카오스토리'
}
// 플랫폼 컬러
export const PLATFORM_COLORS = {
[PLATFORMS.INSTAGRAM]: 'purple',
[PLATFORMS.INSTAGRAM]: 'pink',
[PLATFORMS.NAVER_BLOG]: 'green',
[PLATFORMS.FACEBOOK]: 'blue',
[PLATFORMS.TWITTER]: 'light-blue',
[PLATFORMS.YOUTUBE]: 'red',
[PLATFORMS.KAKAO]: 'yellow',
[PLATFORMS.KAKAO_STORY]: 'amber'
}
// 콘텐츠 상태
export const CONTENT_STATUS = {
DRAFT: 'draft',
PUBLISHED: 'published',
SCHEDULED: 'scheduled',
ARCHIVED: 'archived',
FAILED: 'failed',
// 플랫폼 아이콘
export const PLATFORM_ICONS = {
[PLATFORMS.INSTAGRAM]: 'mdi-instagram',
[PLATFORMS.NAVER_BLOG]: 'mdi-web',
[PLATFORMS.FACEBOOK]: 'mdi-facebook',
[PLATFORMS.KAKAO_STORY]: 'mdi-chat'
}
export const CONTENT_STATUS_LABELS = {
[CONTENT_STATUS.DRAFT]: '임시저장',
[CONTENT_STATUS.PUBLISHED]: '발행됨',
[CONTENT_STATUS.SCHEDULED]: '예약됨',
[CONTENT_STATUS.ARCHIVED]: '보관됨',
[CONTENT_STATUS.FAILED]: '실패',
}
export const CONTENT_STATUS_COLORS = {
[CONTENT_STATUS.DRAFT]: 'orange',
[CONTENT_STATUS.PUBLISHED]: 'success',
[CONTENT_STATUS.SCHEDULED]: 'info',
[CONTENT_STATUS.ARCHIVED]: 'grey',
[CONTENT_STATUS.FAILED]: 'error',
}
// 매장 업종
export const BUSINESS_TYPES = {
RESTAURANT: 'restaurant',
CAFE: 'cafe',
SNACK_BAR: 'snack_bar',
FAST_FOOD: 'fast_food',
BAKERY: 'bakery',
DESSERT: 'dessert',
CONVENIENCE: 'convenience',
OTHER: 'other',
}
export const BUSINESS_TYPE_LABELS = {
[BUSINESS_TYPES.RESTAURANT]: '일반음식점',
[BUSINESS_TYPES.CAFE]: '카페',
[BUSINESS_TYPES.SNACK_BAR]: '분식점',
[BUSINESS_TYPES.FAST_FOOD]: '패스트푸드',
[BUSINESS_TYPES.BAKERY]: '제과점',
[BUSINESS_TYPES.DESSERT]: '디저트카페',
[BUSINESS_TYPES.CONVENIENCE]: '편의점',
[BUSINESS_TYPES.OTHER]: '기타',
// 플랫폼 사양 정의 (누락된 PLATFORM_SPECS 추가)
export const PLATFORM_SPECS = {
[PLATFORMS.INSTAGRAM]: {
name: '인스타그램',
icon: 'mdi-instagram',
color: 'pink',
maxLength: 2200,
hashtags: true,
imageRequired: true,
format: 'sns'
},
[PLATFORMS.NAVER_BLOG]: {
name: '네이버 블로그',
icon: 'mdi-web',
color: 'green',
maxLength: 5000,
hashtags: false,
imageRequired: false,
format: 'blog'
},
[PLATFORMS.FACEBOOK]: {
name: '페이스북',
icon: 'mdi-facebook',
color: 'blue',
maxLength: 63206,
hashtags: true,
imageRequired: false,
format: 'sns'
},
[PLATFORMS.KAKAO_STORY]: {
name: '카카오스토리',
icon: 'mdi-chat',
color: 'amber',
maxLength: 1000,
hashtags: true,
imageRequired: false,
format: 'sns'
}
}
// 톤앤매너
export const TONE_AND_MANNER = {
FRIENDLY: 'friendly',
PROFESSIONAL: 'professional',
HUMOROUS: 'humorous',
ELEGANT: 'elegant',
CASUAL: 'casual',
TRENDY: 'trendy',
}
export const TONE_AND_MANNER_LABELS = {
[TONE_AND_MANNER.FRIENDLY]: '친근함',
[TONE_AND_MANNER.PROFESSIONAL]: '전문적',
[TONE_AND_MANNER.HUMOROUS]: '유머러스',
[TONE_AND_MANNER.ELEGANT]: '고급스러운',
[TONE_AND_MANNER.CASUAL]: '캐주얼',
[TONE_AND_MANNER.TRENDY]: '트렌디',
HUMOROUS: 'humorous'
}
// 감정 강도
export const EMOTION_INTENSITY = {
CALM: 'calm',
NORMAL: 'normal',
ENTHUSIASTIC: 'enthusiastic',
EXCITING: 'exciting',
}
export const EMOTION_INTENSITY_LABELS = {
[EMOTION_INTENSITY.CALM]: '차분함',
[EMOTION_INTENSITY.NORMAL]: '보통',
[EMOTION_INTENSITY.ENTHUSIASTIC]: '열정적',
[EMOTION_INTENSITY.EXCITING]: '과장된',
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high'
}
// 프로모션 타입
export const PROMOTION_TYPES = {
DISCOUNT: 'discount',
EVENT: 'event',
NEW_MENU: 'new_menu',
NONE: 'none',
DISCOUNT: 'DISCOUNT',
EVENT: 'EVENT',
NEW_PRODUCT: 'NEW_PRODUCT',
REVIEW: 'REVIEW'
}
export const PROMOTION_TYPE_LABELS = {
[PROMOTION_TYPES.DISCOUNT]: '할인 정보',
[PROMOTION_TYPES.EVENT]: '이벤트 정보',
[PROMOTION_TYPES.NEW_MENU]: '신메뉴 알림',
[PROMOTION_TYPES.NONE]: '없음',
}
// 이미지 스타일
// 사진 스타일
export const PHOTO_STYLES = {
MODERN: 'modern',
CLASSIC: 'classic',
EMOTIONAL: 'emotional',
VINTAGE: 'vintage',
MINIMALIST: 'minimalist',
COLORFUL: 'colorful',
BRIGHT: 'bright',
CALM: 'calm',
NATURAL: 'natural'
}
export const PHOTO_STYLE_LABELS = {
[PHOTO_STYLES.MODERN]: '모던',
[PHOTO_STYLES.CLASSIC]: '클래식',
[PHOTO_STYLES.EMOTIONAL]: '감성적',
[PHOTO_STYLES.MINIMALIST]: '미니멀',
// 콘텐츠 상태
export const CONTENT_STATUS = {
DRAFT: 'draft',
PUBLISHED: 'published',
ARCHIVED: 'archived'
}
// 파일 업로드 제한
export const FILE_LIMITS = {
MAX_SIZE: 10 * 1024 * 1024, // 10MB
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
// 타겟 대상
export const TARGET_TYPES = {
NEW_MENU: 'new_menu',
DISCOUNT: 'discount',
STORE: 'store',
EVENT: 'event'
}
// 타겟 대상 라벨
export const TARGET_TYPE_LABELS = {
[TARGET_TYPES.NEW_MENU]: '신메뉴',
[TARGET_TYPES.DISCOUNT]: '할인 이벤트',
[TARGET_TYPES.STORE]: '매장 홍보',
[TARGET_TYPES.EVENT]: '일반 이벤트'
}
// 백엔드 플랫폼 매핑 (프론트엔드 -> 백엔드)
export const BACKEND_PLATFORM_MAPPING = {
[PLATFORMS.INSTAGRAM]: 'INSTAGRAM',
[PLATFORMS.NAVER_BLOG]: 'NAVER_BLOG',
[PLATFORMS.FACEBOOK]: 'FACEBOOK',
[PLATFORMS.KAKAO_STORY]: 'KAKAO_STORY'
}
// 백엔드에서 프론트엔드로 매핑 (백엔드 -> 프론트엔드)
export const FRONTEND_PLATFORM_MAPPING = {
'INSTAGRAM': PLATFORMS.INSTAGRAM,
'NAVER_BLOG': PLATFORMS.NAVER_BLOG,
'FACEBOOK': PLATFORMS.FACEBOOK,
'KAKAO_STORY': PLATFORMS.KAKAO_STORY
}
// API 응답 상태
export const API_STATUS = {
SUCCESS: 'success',
ERROR: 'error',
LOADING: 'loading',
IDLE: 'idle',
LOADING: 'loading'
}
// 페이지네이션
export const PAGINATION = {
DEFAULT_PAGE_SIZE: 20,
PAGE_SIZE_OPTIONS: [10, 20, 50, 100],
// 페이지 크기
export const PAGE_SIZES = {
SMALL: 10,
MEDIUM: 20,
LARGE: 50
}
// 정렬 방향
export const SORT_DIRECTION = {
ASC: 'asc',
DESC: 'desc'
}
// 날짜 포맷
export const DATE_FORMATS = {
DISPLAY: 'YYYY-MM-DD HH:mm',
API: 'YYYY-MM-DD',
FULL: 'YYYY-MM-DD HH:mm:ss'
}
// 파일 업로드 제한
export const FILE_LIMITS = {
MAX_SIZE: 10485760, // 10MB
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
MAX_FILES: 5
}
// 콘텐츠 생성 제한
export const CONTENT_LIMITS = {
TITLE_MAX_LENGTH: 100,
DESCRIPTION_MAX_LENGTH: 500,
REQUIREMENTS_MAX_LENGTH: 1000,
MAX_HASHTAGS: 30
}
// 알림 타입
export const NOTIFICATION_TYPES = {
SUCCESS: 'success',
ERROR: 'error',
WARNING: 'warning',
INFO: 'info'
}
// 로컬 스토리지 키
export const STORAGE_KEYS = {
AUTH_TOKEN: 'auth_token',
USER_INFO: 'user_info',
APP_SETTINGS: 'app_settings',
CONTENT_FILTERS: 'content_filters',
}
// 시간 관련 상수
export const TIME_FORMATS = {
DATE: 'YYYY-MM-DD',
DATETIME: 'YYYY-MM-DD HH:mm:ss',
TIME: 'HH:mm',
}
export const DATE_RANGES = {
TODAY: 'today',
WEEK: 'week',
MONTH: 'month',
QUARTER: 'quarter',
YEAR: 'year',
ALL: 'all',
}
export const DATE_RANGE_LABELS = {
[DATE_RANGES.TODAY]: '오늘',
[DATE_RANGES.WEEK]: '최근 1주일',
[DATE_RANGES.MONTH]: '최근 1개월',
[DATE_RANGES.QUARTER]: '최근 3개월',
[DATE_RANGES.YEAR]: '최근 1년',
[DATE_RANGES.ALL]: '전체',
ACCESS_TOKEN: 'accessToken',
REFRESH_TOKEN: 'refreshToken',
USER_INFO: 'userInfo',
THEME: 'theme',
LANGUAGE: 'language'
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -122,7 +122,7 @@
<div class="y-axis-labels">
<div v-for="(label, i) in yAxisLabels" :key="i"
class="y-label"
:style="{ bottom: `${i * 20}%` }">
:style="{ bottom: `${i * 18}%` }">
{{ label }}
</div>
</div>
@ -139,7 +139,7 @@
ref="chartCanvas"
class="chart-canvas"
width="800"
height="300"
height="600"
@mousemove="handleMouseMove"
@mouseleave="hideTooltip">
</canvas>
@ -176,7 +176,7 @@
<!-- X축 라벨 - 데이터 포인트와 동일한 위치에 배치 -->
<div class="x-axis-labels mt-3" style="position: relative; height: 20px;">
<div class="x-axis-container" style="position: relative; padding-left: 60px; padding-right: 20px;">
<div class="x-axis-container" style="position: relative; padding-left: 60px; padding-right: 60px;">
<span
v-for="(point, index) in chartDataPoints"
:key="index"
@ -987,6 +987,31 @@ const updateAiRecommendation = (aiData) => {
}
/**
* Fallback AI 추천 사용
*/
const useFallbackAiRecommendation = () => {
console.log('Fallback AI 추천 사용')
aiRecommendation.value = {
emoji: '☀️',
title: '여름 시즌 마케팅 전략',
sections: {
ideas: {
title: '1. 기본 추천사항',
items: [
'계절 메뉴 개발 및 프로모션',
'SNS 마케팅 활용',
'지역 고객 대상 이벤트 기획'
]
},
costs: {
title: '2. 기대 효과',
items: ['매출 향상', '고객 만족도 증가'],
effects: ['브랜드 인지도 상승', '재방문 고객 증가']
}
}
}
}
// ( )
const currentChartData = computed(() => chartData.value[chartPeriod.value])
@ -995,17 +1020,30 @@ const chartDataPoints = computed(() => {
const data = currentChartData.value
if (!data || data.length === 0) return []
const maxSales = Math.max(...data.map(d => Math.max(d.sales, d.target)))
const maxValue = Math.max(...data.map(d => Math.max(d.sales, d.target)))
// Canvas padding
const padding = 60 // drawChart padding
const canvasWidth = 800 // Canvas width
const canvasHeight = 300 // Canvas height
const chartWidth = canvasWidth - padding * 2
const chartHeight = canvasHeight - padding * 2
return data.map((item, index) => {
const chartStartPercent = 8
const chartEndPercent = 92
const chartWidth = chartEndPercent - chartStartPercent
// Canvas
const canvasX = padding + (index * chartWidth / (data.length - 1))
const canvasY = padding + chartHeight - ((item.sales / maxValue) * chartHeight)
const targetCanvasY = padding + chartHeight - ((item.target / maxValue) * chartHeight)
// , data-points padding
const xPercent = (canvasX / canvasWidth) * 100
const yPercent = ((canvasHeight - canvasY + padding) / canvasHeight) * 100 - 15
const targetYPercent = ((canvasHeight - targetCanvasY + padding) / canvasHeight) * 100 - 15
return {
x: chartStartPercent + (index * chartWidth / (data.length - 1)),
y: 10 + ((item.sales / maxSales) * 80),
targetY: 10 + ((item.target / maxSales) * 80),
x: xPercent,
y: yPercent,
targetY: targetYPercent,
sales: item.sales,
target: item.target,
label: item.label
@ -1193,10 +1231,6 @@ const showDataTooltip = (index, event) => {
if (!data) return
//
const chartArea = event.target.closest('.chart-area')
const rect = chartArea.getBoundingClientRect()
// ,
let actualSales, actualTarget
@ -1215,8 +1249,8 @@ const showDataTooltip = (index, event) => {
tooltip.value = {
show: true,
x: event.clientX - rect.left,
y: event.clientY - rect.top - 80,
x: event.clientX,
y: event.clientY - 80,
title: data.label,
sales: actualSales,
target: actualTarget
@ -1453,8 +1487,7 @@ onMounted(async () => {
left: 0;
top: 0;
bottom: 0;
width: 40px;
z-index: 0;
width: 50px;
}
.y-label {
@ -1466,11 +1499,10 @@ onMounted(async () => {
.chart-grid {
position: absolute;
left: 40px;
right: 0;
left: 60px;
right: 60px;
top: 0;
bottom: 0;
z-index: 0;
}
.grid-line {
@ -1487,16 +1519,14 @@ onMounted(async () => {
top: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.data-points {
position: absolute;
left: 40px;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 2;
}
.data-point {
@ -1547,8 +1577,8 @@ onMounted(async () => {
}
.chart-tooltip {
position: absolute;
z-index: 99999;
position: fixed;
z-index: 1000;
pointer-events: none;
}
@ -1566,6 +1596,10 @@ onMounted(async () => {
margin-bottom: 4px;
}
.tooltip-sales,
.tooltip-target {
margin: 2px 0;
}
/* AI 추천 카드 새로운 스타일 */
.ai-recommend-card {

View File

@ -29,7 +29,7 @@
@keyup.enter="handleLogin"
/>
<!-- 비밀번호 입력 -->
<!-- 비밀번호 입력 -->
<v-text-field
v-model="credentials.password"
label="비밀번호"
@ -45,6 +45,7 @@
@keyup.enter="handleLogin"
/>
<!-- 로그인 옵션 -->
<div class="d-flex justify-space-between align-center mb-6">
<v-checkbox

View File

@ -0,0 +1,235 @@
//* src/views/PosterCreationView.vue
<template>
<v-container fluid class="pa-4">
<!-- 헤더 -->
<div class="d-flex align-center mb-6">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.go(-1)"
class="mr-2"
/>
<div>
<h1 class="text-h4 font-weight-bold">홍보 포스터 생성</h1>
<p class="text-subtitle-1 text-grey-600 mt-1">AI를 활용하여 매력적인 홍보 포스터를 생성하세요</p>
</div>
</div>
<!-- 메인 컨텐츠 -->
<v-row>
<!-- 왼쪽: 입력 -->
<v-col cols="12" md="6">
<v-card class="elevation-2">
<v-card-title class="bg-primary">
<v-icon class="mr-2">mdi-form-textbox</v-icon>
포스터 정보 입력
</v-card-title>
<v-card-text class="pa-6">
<PosterForm
v-model="posterForm"
:loading="loading"
@generate="generatePoster"
@save="savePoster"
/>
</v-card-text>
</v-card>
</v-col>
<!-- 오른쪽: 미리보기 -->
<v-col cols="12" md="6">
<v-card class="elevation-2">
<v-card-title class="bg-secondary">
<v-icon class="mr-2">mdi-image-outline</v-icon>
미리보기
</v-card-title>
<v-card-text class="pa-6">
<PosterPreview
:poster-data="generatedPoster"
:loading="loading"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 로딩 오버레이 -->
<v-overlay v-model="loading" class="align-center justify-center">
<div class="text-center">
<v-progress-circular
indeterminate
size="64"
color="primary"
/>
<div class="text-h6 mt-4">{{ loadingMessage }}</div>
<div class="text-body-2 text-grey-600 mt-2">{{ loadingSubMessage }}</div>
</div>
</v-overlay>
<!-- 성공/오류 스낵바 -->
<v-snackbar v-model="showSuccess" color="success" timeout="4000">
<v-icon class="mr-2">mdi-check-circle</v-icon>
{{ successMessage }}
</v-snackbar>
<v-snackbar v-model="showError" color="error" timeout="6000">
<v-icon class="mr-2">mdi-alert-circle</v-icon>
{{ errorMessage }}
</v-snackbar>
</v-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import PosterForm from '@/components/poster/PosterForm.vue'
import PosterPreview from '@/components/poster/PosterPreview.vue'
import { usePosterStore } from '@/store/poster'
import { useAuthStore } from '@/store/auth'
/**
* 홍보 포스터 생성 페이지
* - 포스터 정보 입력
* - AI 포스터 생성
* - 실시간 미리보기
* - 포스터 저장
*/
//
const posterStore = usePosterStore()
const authStore = useAuthStore()
const router = useRouter()
//
const loading = ref(false)
const loadingMessage = ref('')
const loadingSubMessage = ref('')
//
const posterForm = ref({
storeId: null,
title: '',
targetAudience: '메뉴',
promotionStartDate: null,
promotionEndDate: null,
eventName: '',
imageStyle: '모던',
promotionType: '할인 정보',
emotionIntensity: '보통',
images: [],
category: '이벤트',
requirement: '',
toneAndManner: '전문적',
startDate: null,
endDate: null,
photoStyle: '밝고 화사한'
})
//
const generatedPoster = ref(null)
//
const showSuccess = ref(false)
const showError = ref(false)
const successMessage = ref('')
const errorMessage = ref('')
/**
* 포스터 생성
*/
const generatePoster = async (formData) => {
try {
loading.value = true
loadingMessage.value = 'AI 포스터 생성 중...'
loadingSubMessage.value = '멋진 포스터를 만들고 있어요'
const result = await posterStore.generatePoster({
...formData,
storeId: authStore.currentStore?.id || 1
})
if (result.success) {
generatedPoster.value = result.data
successMessage.value = '포스터가 성공적으로 생성되었습니다!'
showSuccess.value = true
} else {
throw new Error(result.message || '포스터 생성에 실패했습니다.')
}
} catch (error) {
console.error('포스터 생성 오류:', error)
errorMessage.value = error.message || '포스터 생성 중 오류가 발생했습니다.'
showError.value = true
} finally {
loading.value = false
}
}
/**
* 포스터 저장
*/
const savePoster = async () => {
if (!generatedPoster.value) {
errorMessage.value = '저장할 포스터가 없습니다. 먼저 포스터를 생성해주세요.'
showError.value = true
return
}
try {
loading.value = true
loadingMessage.value = '포스터 저장 중...'
loadingSubMessage.value = '잠시만 기다려주세요'
const result = await posterStore.savePoster({
contentId: generatedPoster.value.contentId,
storeId: authStore.currentStore?.id || 1,
title: posterForm.value.title,
content: generatedPoster.value.content,
images: [generatedPoster.value.posterImage],
status: 'PUBLISHED',
category: posterForm.value.category,
requirement: posterForm.value.requirement,
toneAndManner: posterForm.value.toneAndManner,
emotionIntensity: posterForm.value.emotionIntensity,
eventName: posterForm.value.eventName,
startDate: posterForm.value.startDate,
endDate: posterForm.value.endDate,
photoStyle: posterForm.value.photoStyle
})
if (result.success) {
successMessage.value = '포스터가 성공적으로 저장되었습니다!'
showSuccess.value = true
// 3
setTimeout(() => {
router.push('/content')
}, 3000)
} else {
throw new Error(result.message || '포스터 저장에 실패했습니다.')
}
} catch (error) {
console.error('포스터 저장 오류:', error)
errorMessage.value = error.message || '포스터 저장 중 오류가 발생했습니다.'
showError.value = true
} finally {
loading.value = false
}
}
//
onMounted(() => {
//
if (authStore.currentStore) {
posterForm.value.storeId = authStore.currentStore.id
}
})
</script>
<style scoped>
.v-card-title {
color: white;
}
</style>

File diff suppressed because it is too large Load Diff