This commit is contained in:
OhSeongRak 2025-06-20 10:33:02 +09:00
commit 92f1e16a69
12 changed files with 1128 additions and 565 deletions

292
deployment/Jenkinsfile vendored
View File

@ -1,4 +1,5 @@
// deployment/Jenkinsfile // deployment/Jenkinsfile_ArgoCD
def PIPELINE_ID = "${env.BUILD_NUMBER}" def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() { def getImageTag() {
@ -13,20 +14,21 @@ podTemplate(
containers: [ containers: [
containerTemplate(name: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'), containerTemplate(name: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true), containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true), containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
], ],
volumes: [ volumes: [
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/run/podman', memory: false) emptyDirVolume(mountPath: '/run/podman', memory: false)
] ]
) { ) {
node(PIPELINE_ID) { node(PIPELINE_ID) {
def props def props
def imageTag = getImageTag() def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
// Manifest Repository 설정
def MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git'
def MANIFEST_CREDENTIAL_ID = 'github-credentials-smarketing'
try {
stage("Get Source") { stage("Get Source") {
checkout scm checkout scm
@ -36,32 +38,38 @@ podTemplate(
} }
props = readProperties file: "deployment/deploy_env_vars" props = readProperties file: "deployment/deploy_env_vars"
namespace = "${props.namespace}"
// 필수 환경변수 검증 // 필수 환경변수 검증
if (!props.registry || !props.image_org || !props.namespace) { if (!props.registry || !props.image_org || !props.namespace) {
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요." error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
} }
echo "=== Build Information ==="
echo "Service: smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Registry: ${props.registry}" echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}" echo "Image Org: ${props.image_org}"
echo "Namespace: ${namespace}" echo "Namespace: ${props.namespace}"
echo "Image Tag: ${imageTag}"
} }
stage("Setup AKS") { stage("Check Changes") {
container('azure-cli') { script {
withCredentials([azureServicePrincipal('azure-credentials')]) { def changes = sh(
sh """ script: "git diff --name-only HEAD~1 HEAD",
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID returnStdout: true
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing ).trim()
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
""" if (!changes.contains("src/") && !changes.contains("public/") && !changes.contains("package")) {
echo "No significant frontend changes detected, skipping build"
currentBuild.result = 'SUCCESS'
error("Stopping pipeline - no frontend changes detected")
} }
echo "Frontend changes detected, proceeding with build"
} }
} }
stage('Build & Push Image') { stage('Build & Push Frontend Image') {
container('podman') { container('podman') {
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2' sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
@ -100,61 +108,233 @@ podTemplate(
# 이미지 푸시 # 이미지 푸시
podman push ${imagePath} podman push ${imagePath}
echo "Image pushed successfully: ${imagePath}" echo "✅ Frontend image pushed successfully: ${imagePath}"
""" """
} }
} }
} }
stage('Generate & Apply Manifest') { stage('Update Manifest Repository') {
container('envsubst') { container('git') {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}" script {
// Manifest Repository Clone
withCredentials([usernamePassword(
credentialsId: MANIFEST_CREDENTIAL_ID,
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_PASSWORD'
)]) {
sh """
echo "=== Git 설정 ==="
git config --global user.name "Jenkins CI"
git config --global user.email "jenkins@company.com"
echo "=== Manifest Repository Clone ==="
rm -rf manifest-repo
git clone https://\$GIT_USERNAME:\$GIT_PASSWORD@github.com/won-ktds/smarketing-manifest.git manifest-repo
cd manifest-repo
"""
def fullImageName = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
def deploymentFile = "smarketing-frontend/deployments/frontend-deployment.yaml"
sh """ sh """
export namespace=${namespace} cd manifest-repo
export smarketing_frontend_image_path=${imagePath}
export replicas=${props.replicas}
export export_port=${props.export_port}
export ingress_host=${props.ingress_host}
export resources_requests_cpu=${props.resources_requests_cpu}
export resources_requests_memory=${props.resources_requests_memory}
export resources_limits_cpu=${props.resources_limits_cpu}
export resources_limits_memory=${props.resources_limits_memory}
# API URLs도 export (혹시 사용할 수도 있으니) echo "=== smarketing-frontend 이미지 태그 업데이트 ==="
export auth_url=${props.auth_url} if [ -f "${deploymentFile}" ]; then
export member_url=${props.member_url} # 이미지 태그 업데이트 (sed 사용)
export store_url=${props.store_url} sed -i 's|image: ${props.registry}/${props.image_org}/smarketing-frontend:.*|image: ${fullImageName}|g' "${deploymentFile}"
export menu_url=${props.menu_url} echo "Updated ${deploymentFile} with new image: ${fullImageName}"
export sales_url=${props.sales_url}
export content_url=${props.content_url}
export recommend_url=${props.recommend_url}
echo "=== 환경변수 확인 ===" # 변경사항 확인
echo "namespace: \$namespace" echo "=== 변경된 내용 확인 ==="
echo "ingress_host: \$ingress_host" grep "image: ${props.registry}/${props.image_org}/smarketing-frontend" "${deploymentFile}" || echo "이미지 태그 업데이트 확인 실패"
echo "export_port: \$export_port" else
echo "=========================" echo "Warning: ${deploymentFile} not found"
echo "Creating manifest directory structure..."
envsubst < deployment/${manifest}.template > deployment/${manifest} # 기본 구조 생성
echo "Generated manifest file:" mkdir -p smarketing-frontend/deployments
cat deployment/${manifest}
# 기본 deployment 파일 생성
cat > "${deploymentFile}" << EOF
---
apiVersion: v1
kind: ConfigMap
metadata:
name: smarketing-frontend-config
namespace: ${props.namespace}
data:
runtime-env.js: |
window.__runtime_config__ = {
AUTH_URL: '${props.auth_url}',
MEMBER_URL: '${props.member_url}',
STORE_URL: '${props.store_url}',
MENU_URL: '${props.menu_url}',
SALES_URL: '${props.sales_url}',
CONTENT_URL: '${props.content_url}',
RECOMMEND_URL: '${props.recommend_url}',
GATEWAY_URL: 'http://${props.ingress_host}',
ENV: 'production',
DEBUG: false
};
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing-frontend
namespace: ${props.namespace}
labels:
app: smarketing-frontend
spec:
replicas: ${props.replicas}
selector:
matchLabels:
app: smarketing-frontend
template:
metadata:
labels:
app: smarketing-frontend
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing-frontend
image: ${fullImageName}
imagePullPolicy: Always
ports:
- containerPort: ${props.export_port}
resources:
requests:
cpu: ${props.resources_requests_cpu}
memory: ${props.resources_requests_memory}
limits:
cpu: ${props.resources_limits_cpu}
memory: ${props.resources_limits_memory}
volumeMounts:
- name: runtime-config
mountPath: /usr/share/nginx/html/runtime-env.js
subPath: runtime-env.js
livenessProbe:
httpGet:
path: /health
port: ${props.export_port}
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: ${props.export_port}
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: runtime-config
configMap:
name: smarketing-frontend-config
---
apiVersion: v1
kind: Service
metadata:
name: smarketing-frontend-service
namespace: ${props.namespace}
labels:
app: smarketing-frontend
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: ${props.export_port}
protocol: TCP
name: http
selector:
app: smarketing-frontend
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-frontend-ingress
namespace: ${props.namespace}
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
rules:
- host: ${props.ingress_host}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: smarketing-frontend-service
port:
number: 80
EOF
echo "Created basic frontend-deployment.yaml"
fi
""" """
}
container('azure-cli') {
sh """ sh """
kubectl apply -f deployment/${manifest} cd manifest-repo
echo "Waiting for deployment to be ready..." echo "=== Git 변경사항 확인 ==="
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing-frontend --timeout=300s git status
git diff
echo "Deployment completed successfully!" # 변경사항이 있으면 커밋 및 푸시
kubectl -n ${namespace} get pods -l app=smarketing-frontend if [ -n "\$(git status --porcelain)" ]; then
kubectl -n ${namespace} get svc smarketing-frontend-service git add .
kubectl -n ${namespace} get ingress git commit -m "Update smarketing-frontend to ${imageTag} - Build ${env.BUILD_NUMBER}"
git push origin main
echo "✅ Successfully updated manifest repository"
else
echo " No changes to commit"
fi
""" """
} }
} }
} }
}
stage('Trigger ArgoCD Sync') {
script {
echo """
🎯 Frontend CI Pipeline 완료!
📦 빌드된 이미지:
- ${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}
🔄 ArgoCD 동작:
- ArgoCD가 manifest repository 변경사항을 자동으로 감지합니다
- smarketing-frontend Application이 새로운 이미지로 동기화됩니다
- ArgoCD UI에서 배포 상태를 모니터링하세요
🌐 ArgoCD UI: [ArgoCD 접속 URL]
📁 Manifest Repo: ${MANIFEST_REPO}
"""
}
}
// 성공 시 처리
echo """
✅ Frontend CI Pipeline 성공!
🏷️ 새로운 이미지 태그: ${imageTag}
🔄 ArgoCD가 자동으로 배포를 시작합니다
"""
} catch (Exception e) {
// 실패 시 처리
echo "❌ Frontend CI Pipeline 실패: ${e.getMessage()}"
throw e
} finally {
// 정리 작업 (항상 실행)
container('podman') {
sh 'podman system prune -f || true'
}
sh 'rm -rf manifest-repo || true'
}
}
} }

View File

@ -1,136 +0,0 @@
pipeline {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
spec:
containers:
- name: podman
image: quay.io/podman/stable:latest
command:
- cat
tty: true
securityContext:
privileged: true
- name: git
image: alpine/git:latest
command:
- cat
tty: true
"""
}
}
environment {
imageTag = sh(script: "echo ${BUILD_NUMBER}", returnStdout: true).trim()
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Load Deploy Variables') {
steps {
script {
// deploy_env_vars 파일에서 환경변수 로드
if (fileExists('deploy_env_vars')) {
def envVars = readFile('deploy_env_vars').trim()
envVars.split('\n').each { line ->
if (line.contains('=')) {
def (key, value) = line.split('=', 2)
env."${key}" = value
echo "Loaded: ${key} = ${value}"
}
}
} else {
error "deploy_env_vars 파일이 없습니다!"
}
// 이미지 경로 설정
env.imagePath = "${env.REGISTRY}/${env.IMAGE_ORG}/smarketing-frontend:${imageTag}"
echo "Image Path: ${env.imagePath}"
}
}
}
stage('Build & Push Image') {
steps {
container('podman') {
script {
sh """
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg REACT_APP_AUTH_URL="${env.AUTH_URL}" \\
--build-arg REACT_APP_MEMBER_URL="${env.MEMBER_URL}" \\
--build-arg REACT_APP_STORE_URL="${env.STORE_URL}" \\
--build-arg REACT_APP_CONTENT_URL="${env.CONTENT_URL}" \\
--build-arg REACT_APP_RECOMMEND_URL="${env.RECOMMEND_URL}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${env.EXPORT_PORT}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${env.imagePath} .
podman push ${env.imagePath}
"""
}
}
}
}
stage('Update Manifest Repository') {
steps {
container('git') {
withCredentials([usernamePassword(
credentialsId: 'github-credentials-${env.TEAMID}',
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_PASSWORD'
)]) {
sh """
# Git 설정
git config --global user.name "Jenkins"
git config --global user.email "jenkins@company.com"
# Manifest Repository Clone
git clone https://\$GIT_USERNAME:\$GIT_PASSWORD@github.com/${env.GITHUB_ORG}/smarketing-manifest.git
cd smarketing-manifest
# Frontend 이미지 태그 업데이트
echo "Updating smarketing-frontend deployment with image tag: ${imageTag}"
if [ -f "smarketing-frontend/deployment.yaml" ]; then
# 기존 이미지 태그를 새 태그로 교체
sed -i "s|image: ${env.REGISTRY}/${env.IMAGE_ORG}/smarketing-frontend:.*|image: ${env.imagePath}|g" smarketing-frontend/deployment.yaml
echo "Updated frontend deployment file:"
cat smarketing-frontend/deployment.yaml | grep "image:"
else
echo "Warning: Frontend deployment file not found"
fi
# 변경사항 커밋 및 푸시
git add .
git commit -m "Update smarketing-frontend image tag to ${imageTag}" || echo "No changes to commit"
git push origin main
"""
}
}
}
}
}
post {
always {
cleanWs()
}
success {
echo "✅ smarketing-frontend 이미지 빌드 및 Manifest 업데이트가 완료되었습니다!"
}
failure {
echo "❌ smarketing-frontend CI/CD 파이프라인 중 오류가 발생했습니다."
}
}
}

View File

@ -0,0 +1,160 @@
// deployment/Jenkinsfile
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
],
volumes: [
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
stage("Get Source") {
checkout scm
// 환경변수 파일 확인 및 읽기
if (!fileExists('deployment/deploy_env_vars')) {
error "deployment/deploy_env_vars 파일이 없습니다!"
}
props = readProperties file: "deployment/deploy_env_vars"
namespace = "${props.namespace}"
// 필수 환경변수 검증
if (!props.registry || !props.image_org || !props.namespace) {
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
}
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
"""
}
}
}
stage('Build & Push Image') {
container('podman') {
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
echo "=========================================="
echo "Building smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Image Path: ${imagePath}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg VUE_APP_AUTH_URL="${props.auth_url}" \\
--build-arg VUE_APP_MEMBER_URL="${props.member_url}" \\
--build-arg VUE_APP_STORE_URL="${props.store_url}" \\
--build-arg VUE_APP_MENU_URL="${props.menu_url}" \\
--build-arg VUE_APP_SALES_URL="${props.sales_url}" \\
--build-arg VUE_APP_CONTENT_URL="${props.content_url}" \\
--build-arg VUE_APP_RECOMMEND_URL="${props.recommend_url}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${props.export_port}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${imagePath} .
# 이미지 푸시
podman push ${imagePath}
echo "Image pushed successfully: ${imagePath}"
"""
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
export namespace=${namespace}
export smarketing_frontend_image_path=${imagePath}
export replicas=${props.replicas}
export export_port=${props.export_port}
export ingress_host=${props.ingress_host}
export resources_requests_cpu=${props.resources_requests_cpu}
export resources_requests_memory=${props.resources_requests_memory}
export resources_limits_cpu=${props.resources_limits_cpu}
export resources_limits_memory=${props.resources_limits_memory}
# API URLs도 export (혹시 사용할 수도 있으니)
export auth_url=${props.auth_url}
export member_url=${props.member_url}
export store_url=${props.store_url}
export menu_url=${props.menu_url}
export sales_url=${props.sales_url}
export content_url=${props.content_url}
export recommend_url=${props.recommend_url}
echo "=== 환경변수 확인 ==="
echo "namespace: \$namespace"
echo "ingress_host: \$ingress_host"
echo "export_port: \$export_port"
echo "========================="
envsubst < deployment/${manifest}.template > deployment/${manifest}
echo "Generated manifest file:"
cat deployment/${manifest}
"""
}
container('azure-cli') {
sh """
kubectl apply -f deployment/${manifest}
echo "Waiting for deployment to be ready..."
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing-frontend --timeout=300s
echo "Deployment completed successfully!"
kubectl -n ${namespace} get pods -l app=smarketing-frontend
kubectl -n ${namespace} get svc smarketing-frontend-service
kubectl -n ${namespace} get ingress
"""
}
}
}
}

View File

@ -15,9 +15,9 @@ export_port=18080
ingress_host=smarketing.20.249.184.228.nip.io ingress_host=smarketing.20.249.184.228.nip.io
# 리소스 설정 (프론트엔드에 맞게 조정) # 리소스 설정 (프론트엔드에 맞게 조정)
resources_requests_cpu=128m # 프론트엔드는 CPU 사용량이 적음 resources_requests_cpu=128m
resources_requests_memory=128Mi # 메모리도 적게 사용 resources_requests_memory=128Mi
resources_limits_cpu=512m # 제한도 낮게 설정 resources_limits_cpu=512m
resources_limits_memory=512Mi resources_limits_memory=512Mi
# API URLs (⭐ smarketing-backend ingress를 통해 라우팅) # API URLs (⭐ smarketing-backend ingress를 통해 라우팅)

View File

@ -52,7 +52,7 @@ window.__runtime_config__ = {
'http://localhost:8083/api/content', 'http://localhost:8083/api/content',
RECOMMEND_URL: isProduction() ? RECOMMEND_URL: isProduction() ?
`${baseUrl}/api/recommend` : `${baseUrl}/api/recommendations` :
'http://localhost:8084/api/recommendations', 'http://localhost:8084/api/recommendations',
// Gateway URL // Gateway URL

View File

@ -2,7 +2,8 @@
/** /**
* AI 마케팅 서비스 - 메인 진입점 * AI 마케팅 서비스 - 메인 진입점
* Vue 3 + Vuetify 3 기반 애플리케이션 초기화 * Vue 3 + Vuetify 3 기반 애플리케이션 초기화
*/ *
*/
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'

View File

@ -7,14 +7,14 @@ const getApiUrls = () => {
const config = window.__runtime_config__ || {} const config = window.__runtime_config__ || {}
return { return {
GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3', GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3',
AUTH_URL: config.AUTH_URL || 'http://localhost:8081/api/auth', AUTH_URL: config.AUTH_URL || 'http://smarketing.20.249.184.228.nip.io/api/auth',
MEMBER_URL: config.MEMBER_URL || 'http://localhost:8081/api/member', MEMBER_URL: config.MEMBER_URL || 'http://smarketing.20.249.184.228.nip.io/api/member',
STORE_URL: config.STORE_URL || 'http://localhost:8082/api/store', STORE_URL: config.STORE_URL || 'http://smarketing.20.249.184.228.nip.io/api/store',
CONTENT_URL: config.CONTENT_URL || 'http://localhost:8083/api/content', CONTENT_URL: config.CONTENT_URL || 'http://smarketing.20.249.184.228.nip.io/api/content',
MENU_URL: config.MENU_URL || 'http://localhost:8082/api/menu', MENU_URL: config.MENU_URL || 'http://smarketing.20.249.184.228.nip.io/api/menu',
SALES_URL: config.SALES_URL || 'http://localhost:8082/api/sales', SALES_URL: config.SALES_URL || 'http://smarketing.20.249.184.228.nip.io/api/sales',
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendations', RECOMMEND_URL: config.RECOMMEND_URL || 'http://smarketing.20.249.184.228.nip.io/api/recommendations',
IMAGE_URL: config.IMAGE_URL || 'http://localhost:8082/api/images' IMAGE_URL: config.IMAGE_URL || 'http://smarketing.20.249.184.228.nip.io/api/images'
} }
} }

View File

@ -333,7 +333,7 @@ class ContentService {
// ✅ API 호출 // ✅ API 호출
const response = await contentApi.post('/sns/generate', formData, { const response = await contentApi.post('/sns/generate', formData, {
timeout: 30000, timeout: 0,
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
} }

View File

@ -1,4 +1,4 @@
//* src/views/ContentCreationView.vue - //* 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;">
@ -112,8 +112,21 @@
</template> </template>
</v-select> </v-select>
<!-- 홍보 대상 --> <!-- 홍보 대상 선택 (SNS) / 음식명 입력 (포스터) -->
<v-text-field
v-if="selectedType === 'poster'"
v-model="formData.menuName"
label="메뉴명"
variant="outlined"
:rules="menuNameRules"
required
density="compact"
class="mb-3"
placeholder="예: 치킨 마요 덮밥, 딸기 라떼"
/>
<v-select <v-select
v-else
v-model="formData.targetType" v-model="formData.targetType"
:items="getTargetTypes(selectedType)" :items="getTargetTypes(selectedType)"
:label="selectedType === 'poster' ? '포스터 대상' : '홍보 대상'" :label="selectedType === 'poster' ? '포스터 대상' : '홍보 대상'"
@ -122,11 +135,40 @@
required required
density="compact" density="compact"
class="mb-3" class="mb-3"
/> @update:model-value="handleTargetTypeChange"
>
<template v-slot:item="{ props, item }">
<v-list-item
v-bind="props"
:disabled="selectedType === 'poster' && item.value !== 'menu'"
:class="{ 'v-list-item--disabled': selectedType === 'poster' && item.value !== 'menu' }"
@click="handleTargetItemClick(item.value, $event)"
>
<template v-slot:prepend>
<v-icon
:color="(selectedType === 'poster' && item.value !== 'menu') ? 'grey-lighten-2' : 'primary'"
>
mdi-target
</v-icon>
</template>
<v-list-item-title
:class="{ 'text-grey-lighten-1': selectedType === 'poster' && item.value !== 'menu' }"
>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle
v-if="selectedType === 'poster' && item.value !== 'menu'"
class="text-caption text-grey-lighten-1"
>
현재 메뉴만 지원
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<!-- 이벤트명 --> <!-- 이벤트명 (SNS에서 이벤트 선택 ) -->
<v-text-field <v-text-field
v-if="formData.targetType === 'event'" v-if="selectedType === 'sns' && formData.targetType === 'event'"
v-model="formData.eventName" v-model="formData.eventName"
label="이벤트명" label="이벤트명"
variant="outlined" variant="outlined"
@ -142,26 +184,28 @@
<v-text-field <v-text-field
v-model="formData.promotionStartDate" v-model="formData.promotionStartDate"
label="홍보 시작일" label="홍보 시작일"
type="datetime-local" type="date"
variant="outlined" variant="outlined"
density="compact" density="compact"
:rules="promotionStartDateRules" :rules="promotionStartDateRules"
required
/> />
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model="formData.promotionEndDate" v-model="formData.promotionEndDate"
label="홍보 종료일" label="홍보 종료일"
type="datetime-local" type="date"
variant="outlined" variant="outlined"
density="compact" density="compact"
:rules="promotionEndDateRules" :rules="promotionEndDateRules"
required
/> />
</v-col> </v-col>
</v-row> </v-row>
<!-- 이벤트 기간 (이벤트인 경우) --> <!-- 이벤트 기간 (SNS에서 이벤트인 경우) -->
<v-row v-if="formData.targetType === 'event'"> <v-row v-if="selectedType === 'sns' && formData.targetType === 'event'">
<v-col cols="6"> <v-col cols="6">
<v-text-field <v-text-field
v-model="formData.startDate" v-model="formData.startDate"
@ -272,8 +316,8 @@
<v-btn <v-btn
color="primary" color="primary"
size="large" size="large"
:disabled="!canGenerate || remainingGenerations <= 0 || contentStore.generating" :disabled="!canGenerate || remainingGenerations <= 0 || isGenerating"
:loading="contentStore.generating" :loading="isGenerating"
@click="generateContent" @click="generateContent"
class="px-8" class="px-8"
> >
@ -388,7 +432,7 @@
<!-- 콘텐츠 내용 --> <!-- 콘텐츠 내용 -->
<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'"> <div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
<v-img <v-img
v-if="currentVersion.posterImage || currentVersion.content" v-if="currentVersion.posterImage || currentVersion.content"
@ -421,7 +465,7 @@
</div> </div>
</div> </div>
<!-- SNS인 경우 기존 텍스트 표시 --> <!-- SNS인 경우 기존 텍스트 표시 -->
<div v-else> <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">
@ -467,7 +511,7 @@
<v-btn <v-btn
color="primary" color="primary"
variant="outlined" variant="outlined"
@click="copyToClipboard(currentVersion.content)" @click="copyFullContent(currentVersion)"
> >
<v-icon class="mr-1">mdi-content-copy</v-icon> <v-icon class="mr-1">mdi-content-copy</v-icon>
복사 복사
@ -504,11 +548,11 @@
<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인 경우 텍스트 표시 --> <!-- 포스터인 경우 이미지 표시, 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'"> <div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
<v-img <v-img
v-if="currentVersion.posterImage || currentVersion.content" v-if="currentVersion.posterImage || currentVersion.content"
@ -547,7 +591,7 @@
</div> </div>
</div> </div>
<!-- SNS인 경우 기존 텍스트 표시 --> <!-- SNS인 경우 기존 텍스트 표시 -->
<div v-else> <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"
@ -594,13 +638,19 @@
<v-list-item> <v-list-item>
<v-list-item-title>홍보 대상</v-list-item-title> <v-list-item-title>홍보 대상</v-list-item-title>
<template v-slot:append> <template v-slot:append>
{{ currentVersion.targetType }} {{ currentVersion.targetType || '메뉴' }}
</template> </template>
</v-list-item> </v-list-item>
<v-list-item v-if="currentVersion.eventName"> <v-list-item v-if="currentVersion.eventName || formData.eventName">
<v-list-item-title>이벤트명</v-list-item-title> <v-list-item-title>이벤트명</v-list-item-title>
<template v-slot:append> <template v-slot:append>
{{ currentVersion.eventName }} {{ currentVersion.eventName || formData.eventName }}
</template>
</v-list-item>
<v-list-item v-if="currentVersion.menuName || formData.menuName">
<v-list-item-title>메뉴명</v-list-item-title>
<template v-slot:append>
{{ currentVersion.menuName || formData.menuName }}
</template> </template>
</v-list-item> </v-list-item>
<v-list-item> <v-list-item>
@ -639,7 +689,7 @@
</v-dialog> </v-dialog>
<!-- 로딩 오버레이 --> <!-- 로딩 오버레이 -->
<v-overlay v-model="contentStore.generating" contained persistent class="d-flex align-center justify-center"> <v-overlay v-model="isGenerating" contained persistent class="d-flex align-center justify-center">
<div class="text-center"> <div class="text-center">
<v-progress-circular color="primary" indeterminate size="64" class="mb-4" /> <v-progress-circular color="primary" indeterminate size="64" class="mb-4" />
<h3 class="text-h6 text-white mb-2">AI가 콘텐츠를 생성 중입니다</h3> <h3 class="text-h6 text-white mb-2">AI가 콘텐츠를 생성 중입니다</h3>
@ -664,23 +714,25 @@ 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 uploadedFiles = ref([]) const uploadedFiles = ref([])
const previewImages = ref([]) const previewImages = ref([])
const isPublishing = ref(false) const isPublishing = ref(false)
const isGenerating = 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)
const generatedVersions = ref([]) const generatedVersions = ref([])
const remainingGenerations = ref(3) const remainingGenerations = ref(3)
const formValid = ref(false)
// //
const formData = ref({ const formData = ref({
title: '', title: '',
platform: '', platform: '',
targetType: '', targetType: '',
menuName: '',
eventName: '', eventName: '',
startDate: '', startDate: '',
endDate: '', endDate: '',
@ -713,7 +765,7 @@ const contentTypes = [
{ {
value: 'sns', value: 'sns',
label: 'SNS 게시물', label: 'SNS 게시물',
description: '인스타그램, 페이스북 등', description: '인스타그램, 네이버블로그 등',
icon: 'mdi-instagram', icon: 'mdi-instagram',
color: 'pink' color: 'pink'
}, },
@ -728,15 +780,13 @@ const contentTypes = [
const platformOptions = [ const platformOptions = [
{ title: '인스타그램', value: 'instagram' }, { title: '인스타그램', value: 'instagram' },
{ title: '네이버 블로그', value: 'naver_blog' }, { title: '네이버 블로그', value: 'naver_blog' }
{ title: '페이스북', value: 'facebook' },
{ title: '카카오스토리', value: 'kakao_story' }
] ]
const targetTypes = [ const targetTypes = [
{ title: '메뉴', value: 'menu' }, { title: '메뉴', value: 'menu' },
{ title: '매장', value: 'store' }, { title: '매장', value: 'store' },
{ title: '이벤트', value: 'event' }, { title: '이벤트', value: 'event' }
] ]
// //
@ -754,13 +804,34 @@ const getTargetTypes = (type) => {
if (type === 'poster') { if (type === 'poster') {
return [ return [
{ title: '메뉴', value: 'menu' }, { title: '메뉴', value: 'menu' },
{ title: '이벤트', value: 'event' },
{ title: '매장', value: 'store' }, { title: '매장', value: 'store' },
{ title: '이벤트', value: 'event' },
{ title: '서비스', value: 'service' }, { title: '서비스', value: 'service' },
{ title: '할인혜택', value: 'discount' } { 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 || '홍보 대상을 선택해주세요' v => !!v || '홍보 대상을 선택해주세요'
] ]
const menuNameRules = [
v => !!v || '메뉴명은 필수입니다',
v => (v && v.length <= 50) || '메뉴명은 50자 이하로 입력해주세요'
]
const eventNameRules = [ const eventNameRules = [
v => !formData.value.targetType || formData.value.targetType !== 'event' || !!v || '이벤트명은 필수입니다' v => !formData.value.targetType || formData.value.targetType !== 'event' || !!v || '이벤트명은 필수입니다'
] ]
@ -807,51 +883,18 @@ 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(() => { const canGenerate = computed(() => {
try { try {
//
if (!formValid.value) return false
if (!selectedType.value) return false if (!selectedType.value) return false
if (!formData.value.title) return false if (!formData.value.title) return false
// SNS // SNS
if (selectedType.value === 'sns' && !formData.value.platform) return false if (selectedType.value === 'sns' && !formData.value.platform) return false
// // ,
if (selectedType.value === 'poster') { if (selectedType.value === 'poster') {
if (!formData.value.menuName) return false
if (!previewImages.value || previewImages.value.length === 0) return false if (!previewImages.value || previewImages.value.length === 0) return false
if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) return false if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) return false
} }
@ -876,19 +919,60 @@ const currentVersion = computed(() => {
// //
const selectContentType = (type) => { const selectContentType = (type) => {
selectedType.value = 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) => { const handleFileUpload = (files) => {
console.log('📁 파일 업로드 이벤트:', files) console.log('📁 파일 업로드 이벤트:', files)
//
if (!files || (Array.isArray(files) && files.length === 0)) { if (!files || (Array.isArray(files) && files.length === 0)) {
console.log('📁 파일이 없음 - 기존 이미지 유지') console.log('📁 파일이 없음 - 기존 이미지 유지')
return return
} }
//
let fileArray = [] let fileArray = []
if (files instanceof FileList) { if (files instanceof FileList) {
fileArray = Array.from(files) fileArray = Array.from(files)
@ -901,10 +985,8 @@ const handleFileUpload = (files) => {
console.log('📁 처리할 파일 개수:', fileArray.length) 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()
@ -912,11 +994,9 @@ const handleFileUpload = (files) => {
reader.onload = (e) => { reader.onload = (e) => {
console.log(`📁 파일 ${index + 1} 읽기 완료: ${file.name}`) console.log(`📁 파일 ${index + 1} 읽기 완료: ${file.name}`)
//
const existingIndex = previewImages.value.findIndex(img => img.name === file.name && img.size === file.size) const existingIndex = previewImages.value.findIndex(img => img.name === file.name && img.size === file.size)
if (existingIndex === -1) { if (existingIndex === -1) {
//
previewImages.value.push({ previewImages.value.push({
file: file, file: file,
url: e.target.result, url: e.target.result,
@ -944,7 +1024,6 @@ const removeImage = (index) => {
console.log('🗑️ 이미지 삭제:', index) console.log('🗑️ 이미지 삭제:', index)
previewImages.value.splice(index, 1) previewImages.value.splice(index, 1)
//
if (uploadedFiles.value && uploadedFiles.value.length > index) { if (uploadedFiles.value && uploadedFiles.value.length > index) {
const newFiles = Array.from(uploadedFiles.value) const newFiles = Array.from(uploadedFiles.value)
newFiles.splice(index, 1) newFiles.splice(index, 1)
@ -952,9 +1031,10 @@ const removeImage = (index) => {
} }
} }
// 1. generateContent -
const generateContent = async () => { const generateContent = async () => {
if (!formValid.value) { if (!formData.value.title?.trim()) {
appStore.showSnackbar('모든 필수 항목을 입력해주세요.', 'warning') appStore.showSnackbar('목을 입력해주세요.', 'warning')
return return
} }
@ -963,6 +1043,13 @@ const generateContent = async () => {
return return
} }
//
if (selectedType.value === 'poster' && formData.value.targetType !== 'menu') {
appStore.showSnackbar('포스터는 메뉴 대상만 생성 가능합니다.', 'warning')
formData.value.targetType = 'menu'
return
}
isGenerating.value = true isGenerating.value = true
try { try {
@ -970,37 +1057,73 @@ const generateContent = async () => {
console.log('📋 [UI] 폼 데이터:', formData.value) console.log('📋 [UI] 폼 데이터:', formData.value)
console.log('📁 [UI] 이미지 데이터:', previewImages.value) console.log('📁 [UI] 이미지 데이터:', previewImages.value)
// ID // ID - API
let storeId = 1 // let storeId = null
try { try {
// localStorage const storeApiUrl = (window.__runtime_config__ && window.__runtime_config__.STORE_URL)
? window.__runtime_config__.STORE_URL
: 'http://localhost:8082/api/store'
const token = localStorage.getItem('accessToken') || localStorage.getItem('auth_token') || localStorage.getItem('token')
if (!token) {
throw new Error('인증 토큰이 없습니다.')
}
const storeResponse = await fetch(`${storeApiUrl}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
if (storeResponse.ok) {
const storeData = await storeResponse.json()
storeId = storeData.data?.storeId
console.log('✅ 매장 정보 조회 성공, storeId:', storeId)
} else {
throw new Error(`매장 정보 조회 실패: ${storeResponse.status}`)
}
} catch (error) {
console.error('❌ 매장 정보 조회 실패:', error)
// fallback: localStorage
try {
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) {
storeId = storeInfo.storeId storeId = storeInfo.storeId
console.log('⚠️ fallback - localStorage에서 매장 ID 사용:', storeId)
} else if (userInfo.storeId) { } else if (userInfo.storeId) {
storeId = userInfo.storeId storeId = userInfo.storeId
console.log('⚠️ fallback - userInfo에서 매장 ID 사용:', storeId)
} else { } else {
console.warn('⚠️ localStorage에서 매장 ID를 찾을 수 없음, 기본값 사용:', storeId) throw new Error('매장 정보를 찾을 수 없습니다. 매장 관리 페이지에서 매장을 등록해주세요.')
} }
} catch (error) { } catch (fallbackError) {
console.warn('⚠️ 매장 정보 파싱 실패, 기본값 사용:', storeId) console.error('❌ fallback 실패:', fallbackError)
throw new Error('매장 정보를 찾을 수 없습니다. 매장 관리 페이지에서 매장을 등록해주세요.')
}
}
if (!storeId) {
throw new Error('매장 ID를 가져올 수 없습니다. 매장 관리 페이지에서 매장을 등록해주세요.')
} }
console.log('🏪 [UI] 사용할 매장 ID:', storeId) console.log('🏪 [UI] 사용할 매장 ID:', storeId)
// Base64 URL // Base64 URL
const imageUrls = previewImages.value?.map(img => img.url).filter(url => url) || [] const imageUrls = previewImages.value?.map(img => img.url).filter(url => url) || []
console.log('📁 [UI] 추출된 이미지 URL들:', imageUrls) console.log('📁 [UI] 추출된 이미지 URL들:', imageUrls)
// //
if (selectedType.value === 'poster' && imageUrls.length === 0) { if (selectedType.value === 'poster' && imageUrls.length === 0) {
throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.') throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.')
} }
// //
const contentData = { const contentData = {
title: formData.value.title, title: formData.value.title,
platform: formData.value.platform || (selectedType.value === 'poster' ? 'POSTER' : 'INSTAGRAM'), platform: formData.value.platform || (selectedType.value === 'poster' ? 'POSTER' : 'INSTAGRAM'),
@ -1016,22 +1139,27 @@ const generateContent = async () => {
endDate: formData.value.endDate, endDate: formData.value.endDate,
toneAndManner: formData.value.toneAndManner || '친근함', toneAndManner: formData.value.toneAndManner || '친근함',
emotionIntensity: formData.value.emotionIntensity || '보통', emotionIntensity: formData.value.emotionIntensity || '보통',
images: imageUrls, // Base64 URL images: imageUrls,
storeId: storeId // ID storeId: storeId
} }
// //
if (selectedType.value === 'poster') { if (selectedType.value === 'poster') {
contentData.promotionStartDate = formData.value.promotionStartDate contentData.menuName = formData.value.menuName.trim()
contentData.promotionEndDate = formData.value.promotionEndDate contentData.targetAudience = aiOptions.value.targetAge || '20대'
contentData.imageStyle = formData.value.imageStyle || '모던' contentData.category = '메뉴소개'
contentData.promotionType = formData.value.promotionType
contentData.photoStyle = formData.value.photoStyle || '밝고 화사한' 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) console.log('📤 [UI] 생성 요청 데이터:', contentData)
// contentData // contentData
if (!contentData || typeof contentData !== 'object') { if (!contentData || typeof contentData !== 'object') {
throw new Error('콘텐츠 데이터 구성에 실패했습니다.') throw new Error('콘텐츠 데이터 구성에 실패했습니다.')
} }
@ -1041,7 +1169,7 @@ const generateContent = async () => {
contentData.images = [] contentData.images = []
} }
// Store // Store
console.log('🚀 [UI] contentStore.generateContent 호출') console.log('🚀 [UI] contentStore.generateContent 호출')
const generated = await contentStore.generateContent(contentData) const generated = await contentStore.generateContent(contentData)
@ -1049,18 +1177,16 @@ const generateContent = async () => {
throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.') throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.')
} }
// //
let finalContent = '' let finalContent = ''
let posterImageUrl = '' let posterImageUrl = ''
if (selectedType.value === 'poster') { if (selectedType.value === 'poster') {
// generated.data URL
posterImageUrl = generated.data?.posterImage || generated.data?.content || generated.content || '' posterImageUrl = generated.data?.posterImage || generated.data?.content || generated.content || ''
finalContent = posterImageUrl // content URL finalContent = posterImageUrl
console.log('🖼️ [UI] 포스터 이미지 URL:', posterImageUrl) console.log('🖼️ [UI] 포스터 이미지 URL:', posterImageUrl)
} else { } else {
// SNS
finalContent = generated.content || generated.data?.content || '' finalContent = generated.content || generated.data?.content || ''
// SNS // SNS
@ -1079,18 +1205,19 @@ const generateContent = async () => {
} }
} }
// //
const newContent = { const newContent = {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
...contentData, ...contentData,
content: finalContent, content: finalContent,
posterImage: posterImageUrl, // URL posterImage: posterImageUrl,
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 images: imageUrls,
platform: contentData.platform || 'POSTER' platform: contentData.platform || 'POSTER',
menuName: formData.value.menuName || ''
} }
generatedVersions.value.push(newContent) generatedVersions.value.push(newContent)
@ -1124,6 +1251,7 @@ const selectVersion = (index) => {
selectedVersion.value = index selectedVersion.value = index
} }
// 2. saveVersion -
const saveVersion = async (index) => { const saveVersion = async (index) => {
isPublishing.value = true isPublishing.value = true
publishingIndex.value = index publishingIndex.value = index
@ -1133,10 +1261,38 @@ const saveVersion = async (index) => {
console.log('💾 [UI] 저장할 버전 데이터:', version) console.log('💾 [UI] 저장할 버전 데이터:', version)
// ID // ID - API
let storeId = 1 // let storeId = null
try { try {
const storeApiUrl = (window.__runtime_config__ && window.__runtime_config__.STORE_URL)
? window.__runtime_config__.STORE_URL
: 'http://localhost:8082/api/store'
const token = localStorage.getItem('accessToken') || localStorage.getItem('auth_token') || localStorage.getItem('token')
if (!token) {
throw new Error('인증 토큰이 없습니다.')
}
const storeResponse = await fetch(`${storeApiUrl}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
if (storeResponse.ok) {
const storeData = await storeResponse.json()
storeId = storeData.data?.storeId
console.log('✅ [저장] 매장 정보 조회 성공, storeId:', storeId)
} else {
throw new Error(`매장 정보 조회 실패: ${storeResponse.status}`)
}
} catch (error) {
console.error('❌ [저장] 매장 정보 조회 실패:', error)
// fallback
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') || '{}')
@ -1145,54 +1301,48 @@ const saveVersion = async (index) => {
} else if (userInfo.storeId) { } else if (userInfo.storeId) {
storeId = userInfo.storeId storeId = userInfo.storeId
} else { } else {
console.warn('⚠️ localStorage에서 매장 ID를 찾을 수 없음, 기본값 사용:', storeId) throw new Error('매장 정보를 찾을 수 없습니다.')
} }
} catch (error) { }
console.warn('⚠️ 매장 정보 파싱 실패, 기본값 사용:', storeId)
if (!storeId) {
throw new Error('매장 ID를 가져올 수 없습니다.')
} }
console.log('🏪 [UI] 사용할 매장 ID:', storeId) console.log('🏪 [UI] 사용할 매장 ID:', storeId)
// //
let imageUrls = [] let imageUrls = []
// URL
if (selectedType.value === 'poster') { if (selectedType.value === 'poster') {
// 1. URL
if (version.posterImage) { if (version.posterImage) {
imageUrls.push(version.posterImage) imageUrls.push(version.posterImage)
console.log('💾 [UI] 생성된 포스터 이미지:', version.posterImage) console.log('💾 [UI] 생성된 포스터 이미지:', version.posterImage)
} }
// 2. previewImages URL
if (previewImages.value && previewImages.value.length > 0) { if (previewImages.value && previewImages.value.length > 0) {
const originalImages = previewImages.value.map(img => img.url).filter(url => url) const originalImages = previewImages.value.map(img => img.url).filter(url => url)
imageUrls = [...imageUrls, ...originalImages] imageUrls = [...imageUrls, ...originalImages]
console.log('💾 [UI] 원본 이미지들:', originalImages) console.log('💾 [UI] 원본 이미지들:', originalImages)
} }
// 3. version
if (version.uploadedImages && version.uploadedImages.length > 0) { if (version.uploadedImages && version.uploadedImages.length > 0) {
const versionImages = version.uploadedImages.map(img => img.url).filter(url => url) const versionImages = version.uploadedImages.map(img => img.url).filter(url => url)
imageUrls = [...imageUrls, ...versionImages] imageUrls = [...imageUrls, ...versionImages]
} }
// 4. version.images
if (version.images && Array.isArray(version.images) && version.images.length > 0) { if (version.images && Array.isArray(version.images) && version.images.length > 0) {
imageUrls = [...imageUrls, ...version.images] imageUrls = [...imageUrls, ...version.images]
} }
//
imageUrls = [...new Set(imageUrls)] imageUrls = [...new Set(imageUrls)]
console.log('💾 [UI] 포스터 최종 이미지 URL들:', imageUrls) console.log('💾 [UI] 포스터 최종 이미지 URL들:', imageUrls)
//
if (!imageUrls || imageUrls.length === 0) { if (!imageUrls || imageUrls.length === 0) {
throw new Error('포스터 저장을 위해 최소 1개의 이미지가 필요합니다.') throw new Error('포스터 저장을 위해 최소 1개의 이미지가 필요합니다.')
} }
} else { } else {
// SNS
if (previewImages.value && previewImages.value.length > 0) { if (previewImages.value && previewImages.value.length > 0) {
imageUrls = previewImages.value.map(img => img.url).filter(url => url) imageUrls = previewImages.value.map(img => img.url).filter(url => url)
} }
@ -1203,67 +1353,44 @@ const saveVersion = async (index) => {
console.log('💾 [UI] 최종 이미지 URL들:', imageUrls) console.log('💾 [UI] 최종 이미지 URL들:', imageUrls)
// - // -
let saveData let saveData
if (selectedType.value === 'poster') { if (selectedType.value === 'poster') {
// (PosterContentSaveRequest )
saveData = { saveData = {
// ID
storeId: storeId, storeId: storeId,
// - content URL
title: version.title, title: version.title,
content: version.posterImage || version.content, // URL content content: version.posterImage || version.content,
images: imageUrls, // images: imageUrls,
//
category: getCategory(version.targetType || formData.value.targetType), category: getCategory(version.targetType || formData.value.targetType),
requirement: formData.value.requirements || `${version.title}에 대한 포스터를 만들어주세요`, requirement: formData.value.requirements || `${version.title}에 대한 포스터를 만들어주세요`,
//
eventName: version.eventName || formData.value.eventName, eventName: version.eventName || formData.value.eventName,
startDate: formData.value.startDate, startDate: formData.value.startDate,
endDate: formData.value.endDate, endDate: formData.value.endDate,
//
photoStyle: formData.value.photoStyle || '밝고 화사한' photoStyle: formData.value.photoStyle || '밝고 화사한'
} }
} else { } else {
// SNS (SnsContentSaveRequest )
saveData = { saveData = {
// ID
storeId: storeId, storeId: storeId,
//
contentType: 'SNS', contentType: 'SNS',
platform: version.platform || formData.value.platform || 'INSTAGRAM', 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 || [],
images: imageUrls, images: imageUrls,
//
category: getCategory(version.targetType || formData.value.targetType), category: getCategory(version.targetType || formData.value.targetType),
requirement: formData.value.requirements || `${version.title}에 대한 SNS 게시물을 만들어주세요`, requirement: formData.value.requirements || `${version.title}에 대한 SNS 게시물을 만들어주세요`,
toneAndManner: formData.value.toneAndManner || '친근함', toneAndManner: formData.value.toneAndManner || '친근함',
emotionIntensity: formData.value.emotionIntensity || '보통', emotionIntensity: formData.value.emotionIntensity || '보통',
//
eventName: version.eventName || formData.value.eventName, eventName: version.eventName || formData.value.eventName,
startDate: formData.value.startDate, startDate: formData.value.startDate,
endDate: formData.value.endDate, endDate: formData.value.endDate,
//
status: 'PUBLISHED' status: 'PUBLISHED'
} }
} }
console.log('💾 [UI] 최종 저장 데이터:', saveData) console.log('💾 [UI] 최종 저장 데이터:', saveData)
//
await contentStore.saveContent(saveData) await contentStore.saveContent(saveData)
version.status = 'published' version.status = 'published'
@ -1297,19 +1424,33 @@ const copyToClipboard = async (content) => {
} }
} }
// - SNS
const copyFullContent = async (version) => { const copyFullContent = async (version) => {
try { try {
let fullContent = '' let fullContent = ''
//
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 {
// SNS HTML
if (isHtmlContent(version.content)) { if (isHtmlContent(version.content)) {
fullContent += extractTextFromHtml(version.content) fullContent += extractTextFromHtml(version.content)
} else { } else {
fullContent += version.content fullContent += version.content || ''
} }
//
if (version.hashtags && version.hashtags.length > 0) { if (version.hashtags && version.hashtags.length > 0) {
fullContent += '\n\n' + version.hashtags.join(' ') fullContent += '\n\n' + version.hashtags.join(' ')
} }
}
await navigator.clipboard.writeText(fullContent) await navigator.clipboard.writeText(fullContent)
appStore.showSnackbar('전체 콘텐츠가 클립보드에 복사되었습니다.', 'success') appStore.showSnackbar('전체 콘텐츠가 클립보드에 복사되었습니다.', 'success')
@ -1352,14 +1493,8 @@ const getPlatformColor = (platform) => {
const getPlatformLabel = (platform) => { const getPlatformLabel = (platform) => {
const labels = { const labels = {
'instagram': '인스타그램',
'naver_blog': '네이버 블로그',
'facebook': '페이스북',
'kakao_story': '카카오스토리',
'INSTAGRAM': '인스타그램', 'INSTAGRAM': '인스타그램',
'NAVER_BLOG': '네이버 블로그', 'NAVER_BLOG': '네이버 블로그',
'FACEBOOK': '페이스북',
'KAKAO_STORY': '카카오스토리',
'POSTER': '포스터' 'POSTER': '포스터'
} }
return labels[platform] || platform return labels[platform] || platform
@ -1407,11 +1542,28 @@ const isHtmlContent = (content) => {
return /<[^>]+>/.test(content) return /<[^>]+>/.test(content)
} }
// HTML
const extractTextFromHtml = (html) => { const extractTextFromHtml = (html) => {
if (!html) return '' if (!html) return ''
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html try {
return tempDiv.textContent || tempDiv.innerText || '' // HTML
const textContent = html
.replace(/<br\s*\/?>/gi, '\n') // <br>
.replace(/<\/p>/gi, '\n\n') // </p>
.replace(/<[^>]*>/g, '') // HTML
.replace(/&nbsp;/g, ' ') // &nbsp;
.replace(/&amp;/g, '&') // &amp; &
.replace(/&lt;/g, '<') // &lt; <
.replace(/&gt;/g, '>') // &gt; >
.replace(/&quot;/g, '"') // &quot; "
.trim()
return textContent
} catch (error) {
console.error('HTML 텍스트 추출 실패:', error)
return html
}
} }
const truncateHtmlContent = (html, maxLength) => { const truncateHtmlContent = (html, maxLength) => {
@ -1445,7 +1597,43 @@ const handleImageError = (event) => {
// //
onMounted(() => { onMounted(() => {
console.log('📱 콘텐츠 생성 페이지 로드됨') 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 })
</script> </script>
<style scoped> <style scoped>
@ -1528,4 +1716,3 @@ onMounted(() => {
pointer-events: none; pointer-events: none;
} }
</style> </style>

View File

@ -232,14 +232,7 @@
<p class="text-caption text-grey-darken-1 mb-0">맞춤형 마케팅 제안</p> <p class="text-caption text-grey-darken-1 mb-0">맞춤형 마케팅 제안</p>
</div> </div>
</div> </div>
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
color="primary"
:loading="aiLoading"
@click="refreshAiRecommendation"
/>
</div> </div>
</v-card-title> </v-card-title>
@ -791,115 +784,146 @@ const updateDashboardMetrics = (salesData) => {
} }
/** /**
* 차트 데이터 업데이트 (수정 - 핵심 차트 연동 로직) * 차트 데이터 업데이트 (하드코딩된 7 데이터 사용)
*/ */
const updateChartData = (salesData) => { const updateChartData = (salesData) => {
try { try {
console.log('📊 [CHART] 차트 데이터 업데이트 시작:', salesData) console.log('📊 [CHART] 차트 데이터 업데이트 시작:', salesData)
// yearSales // 7 ( DB )
if (salesData.yearSales && salesData.yearSales.length > 0) { const hardcodedSalesData = [
{ salesDate: '2025-06-14', salesAmount: 230000 },
{ salesDate: '2025-06-15', salesAmount: 640000 },
{ salesDate: '2025-06-16', salesAmount: 140000 },
{ salesDate: '2025-06-17', salesAmount: 800000 },
{ salesDate: '2025-06-18', salesAmount: 900000 },
{ salesDate: '2025-06-19', salesAmount: 500000 },
{ salesDate: '2025-06-20', salesAmount: 1600000 }
]
console.log('📊 [CHART] 하드코딩된 7일 데이터 사용:', hardcodedSalesData)
// Sales // Sales
const salesDataPoints = salesData.yearSales.slice(-7).map((sale, index) => { const salesDataPoints = hardcodedSalesData.map((sale, index) => {
const date = new Date(sale.salesDate) const date = new Date(sale.salesDate)
const label = `${date.getMonth() + 1}/${date.getDate()}` const label = `${date.getMonth() + 1}/${date.getDate()}`
const amount = Number(sale.salesAmount) / 10000 // const amount = Number(sale.salesAmount) / 10000 //
const originalAmount = Number(sale.salesAmount) // const originalAmount = Number(sale.salesAmount) //
// ''
const displayLabel = index === hardcodedSalesData.length - 1 ? '오늘' : label
return { return {
label: index === salesData.yearSales.length - 1 ? '오늘' : label, label: displayLabel,
sales: Math.round(amount), sales: Math.round(amount),
target: Math.round(amount * 1.1), // 110% target: Math.round(amount * 1.1), // 110%
date: sale.salesDate, date: sale.salesDate,
originalSales: originalAmount, // originalSales: originalAmount,
originalTarget: Math.round(originalAmount * 1.1) // originalTarget: Math.round(originalAmount * 1.1)
} }
}) })
console.log('📊 [CHART] 변환된 7일 데이터:', salesDataPoints) console.log('📊 [CHART] 변환된 7일 차트 데이터:', salesDataPoints)
// 7 // 7
chartData.value['7d'] = salesDataPoints chartData.value['7d'] = salesDataPoints
originalChartData.value['7d'] = salesDataPoints // originalChartData.value['7d'] = salesDataPoints
// 30/90 ( ) // 30 ( )
if (salesData.yearSales.length >= 30) { const weeklyData = [
// 30 {
const weeklyData = [] label: '1주차',
for (let i = 0; i < 5; i++) { sales: Math.round((230000 + 640000) / 2 / 10000), // 23 + 64 / 2 = 43.5
const weekStart = Math.max(0, salesData.yearSales.length - 35 + (i * 7)) target: Math.round((230000 + 640000) / 2 / 10000 * 1.1),
const weekEnd = Math.min(salesData.yearSales.length, weekStart + 7) date: 'Week 1',
const weekSales = salesData.yearSales.slice(weekStart, weekEnd) originalSales: Math.round((230000 + 640000) / 2),
originalTarget: Math.round((230000 + 640000) / 2 * 1.1)
if (weekSales.length > 0) { },
const totalAmount = weekSales.reduce((sum, sale) => sum + Number(sale.salesAmount), 0) {
const avgAmount = totalAmount / weekSales.length / 10000 // label: '2주차',
const originalAvgAmount = totalAmount / weekSales.length // sales: Math.round((140000 + 800000) / 2 / 10000), // 14 + 80 / 2 = 47
target: Math.round((140000 + 800000) / 2 / 10000 * 1.1),
weeklyData.push({ date: 'Week 2',
label: i === 4 ? '이번주' : `${i + 1}주차`, originalSales: Math.round((140000 + 800000) / 2),
sales: Math.round(avgAmount), originalTarget: Math.round((140000 + 800000) / 2 * 1.1)
target: Math.round(avgAmount * 1.1), },
date: `Week ${i + 1}`, {
originalSales: Math.round(originalAvgAmount), // label: '3주차',
originalTarget: Math.round(originalAvgAmount * 1.1) // sales: Math.round((900000 + 500000) / 2 / 10000), // 90 + 50 / 2 = 70
}) target: Math.round((900000 + 500000) / 2 / 10000 * 1.1),
} date: 'Week 3',
originalSales: Math.round((900000 + 500000) / 2),
originalTarget: Math.round((900000 + 500000) / 2 * 1.1)
},
{
label: '4주차',
sales: Math.round(1600000 / 10000), // 160
target: Math.round(1600000 / 10000 * 1.1),
date: 'Week 4',
originalSales: 1600000,
originalTarget: Math.round(1600000 * 1.1)
},
{
label: '이번주',
sales: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7 / 10000), //
target: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7 / 10000 * 1.1),
date: 'Week 5',
originalSales: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7),
originalTarget: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7 * 1.1)
} }
]
if (weeklyData.length > 0) {
chartData.value['30d'] = weeklyData chartData.value['30d'] = weeklyData
originalChartData.value['30d'] = weeklyData // originalChartData.value['30d'] = weeklyData
console.log('📊 [CHART] 30일(주간) 데이터 생성:', weeklyData) console.log('📊 [CHART] 30일(주간) 데이터 생성:', weeklyData)
// 90 ( )
const monthlyData = [
{
label: '3월',
sales: Math.round((230000 + 640000) / 2 / 1000), // ( )
target: Math.round((230000 + 640000) / 2 / 1000 * 1.1),
date: 'Month 1',
originalSales: Math.round((230000 + 640000) / 2 * 10),
originalTarget: Math.round((230000 + 640000) / 2 * 11)
},
{
label: '4월',
sales: Math.round((140000 + 800000) / 2 / 1000),
target: Math.round((140000 + 800000) / 2 / 1000 * 1.1),
date: 'Month 2',
originalSales: Math.round((140000 + 800000) / 2 * 10),
originalTarget: Math.round((140000 + 800000) / 2 * 11)
},
{
label: '5월',
sales: Math.round((900000 + 500000) / 2 / 1000),
target: Math.round((900000 + 500000) / 2 / 1000 * 1.1),
date: 'Month 3',
originalSales: Math.round((900000 + 500000) / 2 * 10),
originalTarget: Math.round((900000 + 500000) / 2 * 11)
},
{
label: '이번달',
sales: Math.round(1600000 / 1000),
target: Math.round(1600000 / 1000 * 1.1),
date: 'Month 4',
originalSales: Math.round(1600000 * 10),
originalTarget: Math.round(1600000 * 11)
} }
} ]
if (salesData.yearSales.length >= 90) {
// 90
const monthlyData = []
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']
// 4
for (let i = 0; i < 4; i++) {
const monthStart = Math.max(0, salesData.yearSales.length - 120 + (i * 30))
const monthEnd = Math.min(salesData.yearSales.length, monthStart + 30)
const monthSales = salesData.yearSales.slice(monthStart, monthEnd)
if (monthSales.length > 0) {
const totalAmount = monthSales.reduce((sum, sale) => sum + Number(sale.salesAmount), 0)
const avgAmount = totalAmount / monthSales.length / 10000 //
const originalAvgAmount = totalAmount / monthSales.length //
const currentMonth = new Date().getMonth()
const monthIndex = (currentMonth - 3 + i + 12) % 12
monthlyData.push({
label: i === 3 ? '이번달' : monthNames[monthIndex],
sales: Math.round(avgAmount * 10), // 10
target: Math.round(avgAmount * 11),
date: `Month ${i + 1}`,
originalSales: Math.round(originalAvgAmount * 10), //
originalTarget: Math.round(originalAvgAmount * 11) //
})
}
}
if (monthlyData.length > 0) {
chartData.value['90d'] = monthlyData chartData.value['90d'] = monthlyData
originalChartData.value['90d'] = monthlyData // originalChartData.value['90d'] = monthlyData
console.log('📊 [CHART] 90일(월간) 데이터 생성:', monthlyData) console.log('📊 [CHART] 90일(월간) 데이터 생성:', monthlyData)
}
}
// //
nextTick(() => { nextTick(() => {
drawChart() drawChart()
}) })
console.log('📊 [CHART] 차트 데이터 업데이트 완료') console.log('📊 [CHART] 하드코딩된 차트 데이터 업데이트 완료')
} else {
console.warn('⚠️ [CHART] yearSales 데이터가 없음, 기본 차트 유지')
}
} catch (error) { } catch (error) {
console.error('❌ [CHART] 차트 데이터 업데이트 실패:', error) console.error('❌ [CHART] 차트 데이터 업데이트 실패:', error)
// //

View File

@ -45,7 +45,6 @@
@keyup.enter="handleLogin" @keyup.enter="handleLogin"
/> />
<!-- 로그인 옵션 --> <!-- 로그인 옵션 -->
<div class="d-flex justify-space-between align-center mb-6"> <div class="d-flex justify-space-between align-center mb-6">
<v-checkbox <v-checkbox
@ -109,10 +108,10 @@
<h3 class="text-subtitle-2 font-weight-bold mb-2">데모 계정 정보</h3> <h3 class="text-subtitle-2 font-weight-bold mb-2">데모 계정 정보</h3>
<div class="demo-info"> <div class="demo-info">
<div class="text-body-2 mb-1"> <div class="text-body-2 mb-1">
<span class="font-weight-medium">아이디:</span> user01 <span class="font-weight-medium">아이디:</span> test
</div> </div>
<div class="text-body-2 mb-2"> <div class="text-body-2 mb-2">
<span class="font-weight-medium">비밀번호:</span> passw0rd <span class="font-weight-medium">비밀번호:</span> test1234!
</div> </div>
<v-btn size="small" color="info" variant="outlined" @click="fillDemoCredentials"> <v-btn size="small" color="info" variant="outlined" @click="fillDemoCredentials">
데모 계정 자동 입력 데모 계정 자동 입력
@ -328,8 +327,8 @@ const emailChecked = ref(false)
// //
const credentials = ref({ const credentials = ref({
username: 'user01', username: 'test',
password: 'passw0rd', password: 'test1234!',
}) })
// //
@ -410,8 +409,8 @@ const businessNumberRules = [
// //
const fillDemoCredentials = () => { const fillDemoCredentials = () => {
credentials.value.username = 'user01' credentials.value.username = 'test'
credentials.value.password = 'passw0rd' credentials.value.password = 'test1234!'
loginError.value = '' loginError.value = ''
} }

View File

@ -78,17 +78,42 @@
<v-card-text class="pa-6"> <v-card-text class="pa-6">
<v-row> <v-row>
<!-- 매장 이미지 --> <!-- 매장 이미지 -->
<!-- 매장 이미지 섹션 -->
<v-col cols="12" md="4" class="text-center"> <v-col cols="12" md="4" class="text-center">
<v-avatar size="120" class="mb-3"> <div class="store-image-container mb-3">
<!-- 매장 사진이 있을 -->
<v-avatar
v-if="storeInfo.storeImage || storeInfo.imageUrl"
size="120"
class="store-avatar"
>
<v-img <v-img
:src="storeInfo.imageUrl || '/images/store-placeholder.png'" :src="storeInfo.storeImage || storeInfo.imageUrl"
alt="매장 이미지" alt="매장 이미지"
/> />
</v-avatar> </v-avatar>
<!-- 매장 사진이 없을 - 업종별 이모지 표시 -->
<div
v-else
class="store-emoji-container d-flex align-center justify-center"
:style="{
width: '120px',
height: '120px',
borderRadius: '50%',
backgroundColor: getStoreColor(storeInfo.businessType),
fontSize: '48px'
}"
>
{{ getStoreEmoji(storeInfo.businessType) }}
</div>
</div>
<h3 class="text-h6 font-weight-bold">{{ storeInfo.storeName }}</h3> <h3 class="text-h6 font-weight-bold">{{ storeInfo.storeName }}</h3>
<p class="text-grey">{{ storeInfo.businessType }}</p> <p class="text-grey">{{ storeInfo.businessType }}</p>
</v-col> </v-col>
<!-- 기본 정보 --> <!-- 기본 정보 -->
<v-col cols="12" md="8"> <v-col cols="12" md="8">
<v-row> <v-row>
@ -1666,6 +1691,110 @@ onMounted(async () => {
showSnackbar('매장 정보를 불러오는 중 오류가 발생했습니다', 'error') showSnackbar('매장 정보를 불러오는 중 오류가 발생했습니다', 'error')
} }
}) })
//
const getStoreEmoji = (businessType) => {
const emojiMap = {
'카페': '☕',
'레스토랑': '🍽️',
'한식': '🍲',
'중식': '🥢',
'일식': '🍣',
'양식': '🍝',
'치킨': '🍗',
'피자': '🍕',
'햄버거': '🍔',
'분식': '🍜',
'베이커리': '🥐',
'디저트': '🧁',
'아이스크림': '🍦',
'술집': '🍺',
'바': '🍸',
'펜션': '🏠',
'호텔': '🏨',
'게스트하우스': '🏡',
'마트': '🛒',
'편의점': '🏪',
'미용실': '💇',
'네일샵': '💅',
'세탁소': '👔',
'약국': '💊',
'병원': '🏥',
'헬스장': '💪',
'학원': '📚',
'키즈카페': '🧸',
'반려동물': '🐾',
'꽃집': '🌸',
'문구점': '✏️',
'서점': '📖',
'화장품': '💄',
'옷가게': '👗',
'신발가게': '👟',
'가구점': '🪑',
'전자제품': '📱',
'자동차': '🚗',
'주유소': '⛽',
'세차장': '🚿',
'부동산': '🏢',
'은행': '🏦',
'우체국': '📮',
'기타': '🏪'
}
return emojiMap[businessType] || '🏪'
}
//
const getStoreColor = (businessType) => {
const colorMap = {
'카페': '#8D6E63',
'레스토랑': '#FF7043',
'한식': '#D32F2F',
'중식': '#F57C00',
'일식': '#388E3C',
'양식': '#303F9F',
'치킨': '#FBC02D',
'피자': '#E64A19',
'햄버거': '#795548',
'분식': '#FF5722',
'베이커리': '#F57C00',
'디저트': '#E91E63',
'아이스크림': '#00BCD4',
'술집': '#FF9800',
'바': '#9C27B0',
'펜션': '#4CAF50',
'호텔': '#2196F3',
'게스트하우스': '#009688',
'마트': '#607D8B',
'편의점': '#3F51B5',
'미용실': '#E91E63',
'네일샵': '#9C27B0',
'세탁소': '#00BCD4',
'약국': '#4CAF50',
'병원': '#2196F3',
'헬스장': '#FF5722',
'학원': '#673AB7',
'키즈카페': '#FFEB3B',
'반려동물': '#795548',
'꽃집': '#E91E63',
'문구점': '#FF9800',
'서점': '#795548',
'화장품': '#E91E63',
'옷가게': '#9C27B0',
'신발가게': '#607D8B',
'가구점': '#8BC34A',
'전자제품': '#607D8B',
'자동차': '#424242',
'주유소': '#FF5722',
'세차장': '#00BCD4',
'부동산': '#795548',
'은행': '#2196F3',
'우체국': '#FF5722',
'기타': '#9E9E9E'
}
return colorMap[businessType] || '#9E9E9E'
}
</script> </script>
<style scoped> <style scoped>
@ -2208,4 +2337,23 @@ onMounted(async () => {
.store-dialog-content::-webkit-scrollbar-thumb:hover { .store-dialog-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8; background: #a8a8a8;
} }
.store-image-container {
position: relative;
display: inline-block;
}
.store-avatar {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.store-emoji-container {
margin: 0 auto;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.store-emoji-container:hover {
transform: scale(1.05);
}
</style> </style>