StoreManagementView.vue services/api.js edit

This commit is contained in:
unknown 2025-06-18 10:17:32 +09:00
parent bfc4d600e2
commit ccdc2c91bb
5 changed files with 1933 additions and 1505 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -1,4 +1,5 @@
//* src/services/api.js - 수정된 API URL 설정
//* src/services/api.js - 수정된 버전 (createImageApiInstance 함수 추가)
import axios from 'axios'
// 런타임 환경 설정에서 API URL 가져오기
@ -11,10 +12,9 @@ const getApiUrls = () => {
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',
// ⚠️ 수정: 매출 API는 store 서비스 (포트 8082)
SALES_URL: config.SALES_URL || 'http://localhost:8082/api/sales',
// ⚠️ 수정: 추천 API는 ai-recommend 서비스 (포트 8084)
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendations'
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__에 노출됨')

View File

@ -79,8 +79,10 @@ export const useStoreStore = defineStore('store', {
}
},
// src/store/index.js에서 fetchMenus 부분만 수정
/**
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ID 필드 보장
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - 이미지 필드 매핑 수정
*/
async fetchMenus() {
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
@ -106,7 +108,7 @@ export const useStoreStore = defineStore('store', {
console.log('Result.message:', result.message)
if (result.success && result.data) {
// ✅ 메뉴 데이터 ID 필드 보장 처리
// ✅ 백엔드 MenuResponse의 필드명에 맞게 매핑 수정
const menusWithId = (result.data || []).map(menu => {
// ID 필드가 확실히 있도록 보장
const menuId = menu.menuId || menu.id
@ -115,6 +117,8 @@ export const useStoreStore = defineStore('store', {
console.warn('⚠️ 메뉴 ID가 없는 항목 발견:', menu)
}
console.log('메뉴 원본 데이터:', menu) // 디버깅용
return {
...menu,
id: menuId, // ✅ id 필드 확실히 설정
@ -126,12 +130,16 @@ export const useStoreStore = defineStore('store', {
description: menu.description || '',
available: menu.available !== undefined ? menu.available : true,
recommended: menu.recommended !== undefined ? menu.recommended : false,
imageUrl: menu.imageUrl || '/images/menu-placeholder.png'
// ✅ 이미지 필드 수정: 백엔드는 'image' 필드 사용
imageUrl: menu.image || menu.imageUrl || '/images/menu-placeholder.png',
image: menu.image || menu.imageUrl, // 백엔드 호환성
createdAt: menu.createdAt,
updatedAt: menu.updatedAt
}
})
// 메뉴 목록이 있는 경우
console.log('✅ 메뉴 목록 설정 (ID 보장됨):', menusWithId)
console.log('✅ 메뉴 목록 설정 (이미지 필드 매핑 완료):', menusWithId)
this.menus = menusWithId
return { success: true, data: menusWithId }
} else {

View File

@ -1,93 +1,106 @@
//* src/views/ContentManagementView.vue
<template>
<v-container fluid class="pa-4">
<!-- 필터 영역 -->
<v-card class="mb-6">
<v-card-text>
<v-row align="center">
<!-- 콘텐츠 타입 필터 - 형태로 변경 -->
<v-col cols="12" md="6">
<div class="d-flex align-center flex-wrap ga-2">
<span class="text-subtitle-2 font-weight-medium mr-2">콘텐츠 타입:</span>
<v-chip
v-for="type in contentTypeOptions"
:key="type.value"
:color="selectedContentType === type.value ? type.color : 'default'"
:variant="selectedContentType === type.value ? 'flat' : 'outlined'"
size="small"
class="mr-1 chip-hover"
@click="selectContentType(type.value)"
>
<span class="mr-1">{{ type.emoji }}</span>
{{ type.title.replace(type.emoji + ' ', '') }}
<span v-if="type.value !== 'all'" class="ml-1">({{ getTypeCount(type.value) }})</span>
<span v-else class="ml-1">({{ getTotalCount() }})</span>
</v-chip>
<v-container fluid class="pa-6">
<!-- 페이지 헤더 -->
<div class="d-flex align-center mb-6">
<v-icon @click="$router.go(-1)" class="mr-3 cursor-pointer">mdi-arrow-left</v-icon>
<h1 class="text-h4 font-weight-bold">콘텐츠 관리</h1>
</div>
<!-- 컨트롤 영역 -->
<v-row class="mb-4">
<!-- 콘텐츠 타입 필터 -->
<v-col cols="12" md="6">
<v-chip-group
v-model="selectedContentType"
selected-class="text-white"
mandatory
class="mb-3"
>
<v-chip
v-for="option in contentTypeOptions"
:key="option.value"
:value="option.value"
:color="option.color"
@click="selectContentType(option.value)"
class="mr-2"
>
<span class="mr-1">{{ option.emoji }}</span>
{{ option.title }}
</v-chip>
</v-chip-group>
</v-col>
<!-- 검색 새콘텐츠생성 버튼 -->
<!-- 검색 정렬 -->
<v-col cols="12" md="6">
<div class="d-flex align-center ga-2">
<!-- 제목 검색 -->
<div class="d-flex gap-3">
<!-- 검색 -->
<v-text-field
v-model="searchQuery"
label="제목, 해시태그로 검색"
placeholder="제목, 내용, 해시태그로 검색..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
@update:model-value="applyFilters"
clearable
@input="applyFilters"
class="flex-grow-1"
/>
<!-- 콘텐츠 생성 버튼 -->
<!-- 정렬 -->
<v-select
v-model="sortBy"
:items="sortOptions"
item-title="title"
item-value="value"
variant="outlined"
density="compact"
hide-details
style="min-width: 140px;"
/>
</div>
</v-col>
</v-row>
<!-- 액션 버튼 영역 -->
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex align-center">
<span class="text-body-2 text-grey-600">
{{ filteredContents.length }} 콘텐츠
</span>
<v-btn
v-if="selectedItems.length > 0"
color="error"
variant="text"
prepend-icon="mdi-delete"
@click="deleteSelectedItems"
class="ml-4"
>
선택 삭제 ({{ selectedItems.length }})
</v-btn>
</div>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="$router.push('/content/create')"
class="ml-2"
>
콘텐츠 생성
</v-btn>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 선택된 항목 일괄 작업 -->
<div v-if="selectedItems.length > 0" class="mb-4">
<v-alert color="info" variant="tonal">
<div class="d-flex justify-space-between align-center">
<span>{{ selectedItems.length }} 항목이 선택됨</span>
<v-btn
color="error"
variant="text"
@click="deleteSelectedItems"
>
선택 항목 삭제
</v-btn>
</div>
</v-alert>
<!-- 로딩 상태 -->
<div v-if="loading" class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="64" />
<div class="mt-4 text-body-1">콘텐츠를 불러오는 ...</div>
</div>
<!-- 콘텐츠 목록 -->
<v-card>
<v-card-text class="pa-0">
<div v-if="loading" class="text-center pa-8">
<v-progress-circular indeterminate color="primary" />
<div class="mt-4">콘텐츠를 불러오는 ...</div>
</div>
<div v-else-if="paginatedContents.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-file-document-outline</v-icon>
<!-- 콘텐츠가 없는 경우 -->
<div v-else-if="filteredContents.length === 0" class="text-center py-12">
<v-icon size="120" color="grey-lighten-2" class="mb-4">mdi-file-document-outline</v-icon>
<div class="text-h6 mb-2">표시할 콘텐츠가 없습니다</div>
<div class="text-body-2 text-grey-600 mb-4">새로운 콘텐츠를 생성해보세요</div>
<v-btn
color="primary"
@click="$router.push('/content/create')"
>
<v-btn color="primary" @click="$router.push('/content/create')">
콘텐츠 생성하기
</v-btn>
</div>
@ -152,7 +165,7 @@
</v-chip>
</div>
<div class="text-caption text-truncate grey--text" style="max-width: 400px;">
{{ content.content ? content.content.substring(0, 80) + '...' : '' }}
{{ content.content ? content.content.substring(0, 100) + '...' : '' }}
</div>
</div>
</td>
@ -171,13 +184,7 @@
</div>
</td>
<td @click.stop>
<div class="d-flex ga-1">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click="showDetail(content)"
/>
<div class="d-flex">
<v-btn
icon="mdi-pencil"
size="small"
@ -196,130 +203,132 @@
</tr>
</tbody>
</v-table>
</div>
</v-card-text>
</v-card>
<!-- 페이지네이션 -->
<div v-if="totalPages > 1" class="d-flex justify-center mt-6">
<div class="d-flex justify-center mt-6" v-if="totalPages > 1">
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
color="primary"
@update:model-value="scrollToTop"
/>
</div>
</div>
<!-- 상세 보기/수정 다이얼로그 -->
<!-- 상세/수정 다이얼로그 -->
<v-dialog v-model="showDetailDialog" max-width="800px" scrollable>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ isEditMode ? '콘텐츠 수정' : '콘텐츠 상세 정보' }}</span>
<v-btn
icon="mdi-close"
variant="text"
@click="closeDialog"
/>
<span class="text-h6">{{ isEditMode ? '콘텐츠 수정' : '콘텐츠 상세' }}</span>
<v-btn icon="mdi-close" variant="text" @click="closeDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-4" style="max-height: 70vh;">
<v-form ref="editForm" v-model="editFormValid" v-if="selectedContent">
<!-- 제목 -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">제목</label>
<v-card-text v-if="selectedContent">
<v-form ref="editForm" v-model="editFormValid" v-if="isEditMode">
<!-- 수정 모드 -->
<v-text-field
v-if="isEditMode"
v-model="editingContent.title"
label="제목"
:rules="titleRules"
variant="outlined"
density="compact"
class="mb-4"
/>
<div v-else class="text-body-1 font-weight-medium">
{{ selectedContent.title }}
</div>
</div>
<!-- 플랫폼 (수정 비활성화) -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">플랫폼</label>
<v-chip :color="getPlatformColor(selectedContent.platform)" variant="tonal">
{{ getPlatformText(selectedContent.platform) }}
</v-chip>
<div v-if="isEditMode" class="text-caption text-grey-600 mt-1">
플랫폼은 수정할 없습니다.
</div>
</div>
<v-textarea
v-model="editingContent.content"
label="내용"
rows="8"
variant="outlined"
class="mb-4"
/>
<!-- 홍보 기간 -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">홍보 기간</label>
<div v-if="isEditMode" class="d-flex ga-2">
<v-text-field
v-model="editingContent.hashtags"
label="해시태그 (쉼표로 구분)"
variant="outlined"
class="mb-4"
hint="예: #맛집, #신메뉴, #이벤트"
/>
<v-row>
<v-col cols="6">
<v-text-field
v-model="editingContent.startDate"
type="date"
label="시작일"
type="date"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="editingContent.endDate"
type="date"
label="종료일"
type="date"
variant="outlined"
density="compact"
/>
</div>
<div v-else class="text-body-1">
{{ formatDateRange(selectedContent.startDate, selectedContent.endDate) }}
</div>
</v-col>
</v-row>
</v-form>
<div v-else>
<!-- 상세 보기 모드 -->
<div class="mb-4">
<div class="text-subtitle-2 text-grey-600 mb-1">제목</div>
<div class="text-body-1">{{ selectedContent.title }}</div>
</div>
<!-- 내용 (수정 비활성화) -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">내용</label>
<div v-if="isEditMode" class="pa-3 bg-grey-lighten-4 rounded">
<div class="text-body-2 text-grey-600 mb-2">
내용은 수정할 없습니다. 새로 생성해주세요.
</div>
</div>
<div v-else class="text-body-1" style="white-space: pre-wrap;">
{{ selectedContent.content }}
</div>
<div class="text-subtitle-2 text-grey-600 mb-1">플랫폼</div>
<v-chip :color="getPlatformColor(selectedContent.platform)" size="small" variant="tonal">
{{ getPlatformText(selectedContent.platform) }}
</v-chip>
</div>
<!-- 해시태그 (수정 비활성화) -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-2 d-block">해시태그</label>
<div class="d-flex flex-wrap ga-1">
<div class="text-subtitle-2 text-grey-600 mb-1">내용</div>
<div class="text-body-1 content-preview">{{ selectedContent.content }}</div>
</div>
<div class="mb-4" v-if="selectedContent.hashtags && selectedContent.hashtags.length > 0">
<div class="text-subtitle-2 text-grey-600 mb-1">해시태그</div>
<div class="d-flex flex-wrap gap-1">
<v-chip
v-for="tag in selectedContent.hashtags"
:key="tag"
class="mr-1 mb-1"
size="small"
variant="outlined"
color="primary"
>
#{{ tag }}
{{ tag }}
</v-chip>
</div>
<div v-if="isEditMode" class="text-caption text-grey-600 mt-1">
해시태그는 수정할 없습니다. 새로 생성해주세요.
</div>
<div class="mb-4">
<div class="text-subtitle-2 text-grey-600 mb-1">프로모션 기간</div>
<div class="text-body-1">{{ formatDateRange(selectedContent.startDate, selectedContent.endDate) }}</div>
</div>
<div class="mb-4">
<div class="text-subtitle-2 text-grey-600 mb-1">상태</div>
<v-chip :color="getStatusColor(selectedContent.status)" size="small" variant="tonal">
{{ getStatusText(selectedContent.status) }}
</v-chip>
</div>
<div class="mb-4">
<div class="text-subtitle-2 text-grey-600 mb-1">생성일</div>
<div class="text-body-1">{{ formatDateTime(selectedContent.createdAt) }}</div>
</div>
</div>
</v-form>
</v-card-text>
<!-- 개선된 버튼 영역 -->
<v-card-actions class="pa-4">
<v-spacer />
<div v-if="isEditMode" class="d-flex ga-3">
<v-card-actions v-if="selectedContent">
<div v-if="isEditMode" class="d-flex justify-end w-100 gap-2">
<v-btn
variant="outlined"
color="grey"
@click="cancelEdit"
class="px-6"
:disabled="updating"
>
취소
</v-btn>
@ -328,24 +337,23 @@
@click="saveEdit"
:loading="updating"
:disabled="!editFormValid"
class="px-6 elevation-2"
class="px-6 elevation-1"
>
저장
</v-btn>
</div>
<div v-else class="d-flex ga-3">
<div v-else class="d-flex justify-end w-100 gap-2">
<v-btn
variant="outlined"
color="primary"
prepend-icon="mdi-pencil"
@click="showEditMode"
class="px-6 elevation-1"
>
수정
</v-btn>
<v-btn
variant="outlined"
color="error"
variant="outlined"
prepend-icon="mdi-delete"
@click="confirmDelete(selectedContent)"
class="px-6 elevation-1"
@ -372,6 +380,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useContentStore } from '@/store/content'
import { useAuthStore } from '@/store/auth'
/**
* 콘텐츠 관리 화면
@ -383,6 +392,7 @@ import { useContentStore } from '@/store/content'
//
const contentStore = useContentStore()
const authStore = useAuthStore()
const router = useRouter()
//
@ -519,26 +529,73 @@ const totalPages = computed(() => {
const loadContents = async () => {
loading.value = true
try {
await contentStore.loadContents()
console.log('=== 콘텐츠 목록 조회 시작 ===')
// 📋 API
const filters = {
contentType: selectedContentType.value !== 'all' ? selectedContentType.value : null,
platform: getPlatformForAPI(selectedContentType.value),
period: 'all', //
sortBy: sortBy.value || 'latest'
}
console.log('API 요청 필터:', filters)
// 📡 API
await contentStore.loadContents(filters)
console.log('✅ 콘텐츠 로딩 완료, 개수:', contentStore.contents?.length || 0)
} catch (error) {
console.error('콘텐츠 로딩 실패:', error)
console.error('콘텐츠 로딩 실패:', error)
showError.value = true
errorMessage.value = '콘텐츠를 불러오는데 실패했습니다.'
errorMessage.value = error.message || '콘텐츠를 불러오는데 실패했습니다.'
} finally {
loading.value = false
}
}
// API
const getPlatformForAPI = (contentType) => {
const platformMapping = {
'instagram': 'INSTAGRAM',
'blog': 'NAVER_BLOG',
'poster': 'POSTER',
'all': null
}
return platformMapping[contentType] || null
}
const selectContentType = (type) => {
selectedContentType.value = type
currentPage.value = 1
// ( )
//
loadContents()
}
const applyFilters = () => {
currentPage.value = 1
}
const sortByPromotionDate = () => {
//
if (promotionSortOrder.value === 'none') {
promotionSortOrder.value = 'desc'
} else if (promotionSortOrder.value === 'desc') {
promotionSortOrder.value = 'asc'
} else {
promotionSortOrder.value = 'none'
}
}
const toggleSelectAll = () => {
if (selectAll.value) {
selectedItems.value = paginatedContents.value.map(content => content.id)
} else {
selectedItems.value = []
}
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
@ -626,274 +683,123 @@ const deleteSelectedItems = async () => {
}
}
const toggleSelectAll = () => {
if (selectAll.value) {
selectedItems.value = paginatedContents.value.map(content => content.id)
} else {
selectedItems.value = []
//
const getStatusColor = (status) => {
const statusColors = {
'DRAFT': 'orange',
'PUBLISHED': 'green',
'SCHEDULED': 'blue',
'ARCHIVED': 'grey'
}
return statusColors[status] || 'grey'
}
const sortByPromotionDate = () => {
if (promotionSortOrder.value === 'none') {
promotionSortOrder.value = 'asc'
} else if (promotionSortOrder.value === 'asc') {
promotionSortOrder.value = 'desc'
} else {
promotionSortOrder.value = 'none'
const getStatusText = (status) => {
const statusTexts = {
'DRAFT': '임시저장',
'PUBLISHED': '발행됨',
'SCHEDULED': '예약됨',
'ARCHIVED': '보관됨'
}
}
//
const getTotalCount = () => {
return contentStore.contents?.length || 0
}
const getTypeCount = (type) => {
if (type === 'all') return getTotalCount()
return contentStore.contents?.filter(content => {
//
const platformMapping = {
'instagram': ['instagram', 'INSTAGRAM'],
'blog': ['blog', 'NAVER_BLOG', 'naver_blog'],
'poster': ['poster', 'POSTER']
}
const allowedPlatforms = platformMapping[type] || [type]
return allowedPlatforms.includes(content.platform)
}).length || 0
return statusTexts[status] || status
}
const getPlatformColor = (platform) => {
const colors = {
'instagram': 'pink',
const platformColors = {
'INSTAGRAM': 'pink',
'blog': 'green',
'instagram': 'pink',
'NAVER_BLOG': 'green',
'poster': 'orange',
'POSTER': 'orange'
'blog': 'green',
'naver_blog': 'green',
'POSTER': 'orange',
'poster': 'orange'
}
return colors[platform] || 'grey'
return platformColors[platform] || 'grey'
}
const getPlatformText = (platform) => {
const texts = {
'instagram': 'Instagram',
'INSTAGRAM': 'Instagram',
'blog': '네이버 블로그',
const platformTexts = {
'INSTAGRAM': '인스타그램',
'instagram': '인스타그램',
'NAVER_BLOG': '네이버 블로그',
'poster': '포스터',
'POSTER': '포스터'
'blog': '네이버 블로그',
'naver_blog': '네이버 블로그',
'POSTER': '포스터',
'poster': '포스터'
}
return texts[platform] || platform
}
const getStatusColor = (status) => {
const colors = {
'published': 'success',
'draft': 'warning',
'archived': 'grey'
}
return colors[status] || 'grey'
return platformTexts[platform] || platform
}
const formatDateRange = (startDate, endDate) => {
if (!startDate && !endDate) return '-'
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
if (!startDate && !endDate) return '기간 미설정'
if (!endDate) return formatDate(startDate) + ' ~'
if (!startDate) return '~ ' + formatDate(endDate)
return formatDate(startDate) + ' ~ ' + formatDate(endDate)
}
const start = formatDate(startDate)
const end = formatDate(endDate)
if (start && end) {
return `${start} ~ ${end}`
} else if (start) {
return `${start} ~`
} else if (end) {
return `~ ${end}`
}
return '-'
}
//
onMounted(async () => {
loading.value = true
const formatDate = (dateString) => {
if (!dateString) return ''
try {
await contentStore.loadContents()
const date = new Date(dateString)
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
} catch (error) {
console.error('콘텐츠 로딩 실패:', error)
showError.value = true
errorMessage.value = '콘텐츠를 불러오는데 실패했습니다.'
} finally {
loading.value = false
return dateString
}
}
const formatDateTime = (dateString) => {
if (!dateString) return ''
try {
const date = new Date(dateString)
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch (error) {
return dateString
}
}
//
watch(sortBy, () => {
//
promotionSortOrder.value = 'none'
})
//
watch(selectedItems, (newVal) => {
selectAll.value = newVal.length === paginatedContents.value.length && newVal.length > 0
})
//
onMounted(() => {
console.log('🔄 ContentManagementView 마운트됨')
//
watch(promotionSortOrder, (newVal) => {
if (newVal !== 'none') {
//
console.log(`프로모션 기간 정렬: ${newVal === 'asc' ? '오름차순' : '내림차순'}`)
//
if (!authStore.isAuthenticated) {
console.log('❌ 인증되지 않은 사용자')
router.push('/login')
return
}
//
loadContents()
})
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 정렬 가능한 헤더 스타일 */
.sortable-header {
transition: background-color 0.2s ease-in-out;
}
.sortable-header:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.sortable-header .v-icon {
transition: color 0.2s ease-in-out;
.content-preview {
white-space: pre-wrap;
word-break: break-word;
}
/* 칩 hover 효과 강화 */
.v-chip {
.cursor-pointer {
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.v-chip:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chip-hover:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
/* 선택된 칩 강조 */
.v-chip.v-chip--variant-flat {
font-weight: 600;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
/* 버튼 hover 효과 */
.v-btn {
transition: all 0.2s ease-in-out;
}
.v-btn:hover {
transform: translateY(-1px);
}
/* 테이블 행 hover 효과 */
.v-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.04);
}
/* 카드 hover 효과 */
.v-card.cursor-pointer:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease-in-out;
}
/* 다이얼로그 버튼 스타일링 */
.v-card-actions .v-btn {
font-weight: 500;
letter-spacing: 0.5px;
}
.v-card-actions .v-btn.elevation-2:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.v-card-actions .v-btn.elevation-1:hover {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
/* 비활성화된 입력 필드 스타일 */
.bg-grey-lighten-4 {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
}
/* 테이블 스타일 개선 */
.v-table {
border-radius: 8px;
overflow: hidden;
}
.v-table thead th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
position: relative;
}
.v-table tbody td {
vertical-align: middle;
}
/* 필터 카드 스타일 */
.v-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.d-flex.justify-space-between.align-center {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.v-row .v-col {
margin-bottom: 8px;
}
.v-table {
font-size: 0.875rem;
}
.v-table th,
.v-table td {
padding: 8px 4px;
}
.sortable-header {
font-size: 0.8rem;
}
}
/* 정렬 아이콘 애니메이션 */
@keyframes sortActive {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.sortable-header .v-icon[style*="color: rgb(25, 118, 210)"] {
animation: sortActive 0.3s ease-in-out;
}
</style>

File diff suppressed because it is too large Load Diff