storeManagement contentmanagement edit
This commit is contained in:
parent
f8c7ecc261
commit
02765ba943
@ -1,6 +1,23 @@
|
||||
//* src/views/ContentManagementView.vue
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 페이지 헤더 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center mb-4">
|
||||
<v-btn
|
||||
icon
|
||||
@click="$router.go(-1)"
|
||||
class="mr-3"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<h1 class="text-h5">📝 콘텐츠 관리</h1>
|
||||
</div>
|
||||
<p class="text-subtitle-1 grey--text">생성된 콘텐츠를 관리하고 성과를 분석합니다</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 콘텐츠 타입 필터 (상단 이동) -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
@ -10,8 +27,8 @@
|
||||
<div class="d-flex align-center flex-wrap">
|
||||
<div class="text-subtitle-2 mr-4 mb-2">콘텐츠 타입:</div>
|
||||
<v-chip-group
|
||||
v-model="selectedContentTypes"
|
||||
multiple
|
||||
v-model="selectedContentType"
|
||||
mandatory
|
||||
@update:model-value="applyContentTypeFilter"
|
||||
class="mb-2"
|
||||
>
|
||||
@ -75,8 +92,76 @@
|
||||
|
||||
<!-- 메인 콘텐츠 영역 -->
|
||||
<v-row>
|
||||
<!-- 콘텐츠 목록 -->
|
||||
<v-col cols="12" md="9">
|
||||
<!-- 좌측 사이드바 필터 - Desktop 비율 수정 -->
|
||||
<v-col cols="12" lg="3" md="4">
|
||||
<v-card elevation="2" height="400" class="sticky-sidebar">
|
||||
<v-card-title class="text-h6 pa-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-filter</v-icon>
|
||||
추가 필터
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- 상태 필터 -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">상태</div>
|
||||
<v-checkbox
|
||||
v-model="filters.published"
|
||||
label="게시됨 (18)"
|
||||
color="success"
|
||||
@change="applyFilters"
|
||||
density="compact"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="filters.draft"
|
||||
label="임시저장 (6)"
|
||||
color="orange"
|
||||
@change="applyFilters"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 기간 필터 -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">기간</div>
|
||||
<v-select
|
||||
v-model="filters.period"
|
||||
label="전체 기간"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:items="periodOptions"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 정렬 필터 -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">정렬</div>
|
||||
<v-select
|
||||
v-model="sortBy"
|
||||
label="최신순"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:items="sortOptions"
|
||||
@update:model-value="applySorting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 필터 초기화 -->
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="resetFilters"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-refresh</v-icon>
|
||||
필터 초기화
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- 콘텐츠 목록 - Desktop 비율 수정 -->
|
||||
<v-col cols="12" lg="9" md="8">
|
||||
<v-card elevation="2">
|
||||
<!-- 상단 툴바 -->
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-4">
|
||||
@ -102,6 +187,29 @@
|
||||
선택 삭제 ({{ selectedItems.length }})
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 뷰 옵션 -->
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
class="mr-3"
|
||||
>
|
||||
<v-btn icon size="small" value="list">
|
||||
<v-icon>mdi-view-list</v-icon>
|
||||
</v-btn>
|
||||
<v-btn icon size="small" value="grid">
|
||||
<v-icon>mdi-view-grid</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<!-- 새 콘텐츠 생성 -->
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="$router.push('/content/create')"
|
||||
>
|
||||
<v-icon class="mr-1">mdi-plus</v-icon>
|
||||
새 콘텐츠
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
@ -131,63 +239,113 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<!-- 리스트 뷰 - 테이블 형태 -->
|
||||
<div v-else-if="viewMode === 'list'">
|
||||
<v-list>
|
||||
<template v-for="(content, index) in paginatedContents" :key="content.id">
|
||||
<v-list-item
|
||||
class="px-4 py-3"
|
||||
@click="showDetail(content)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">
|
||||
<v-checkbox
|
||||
v-model="selectAll"
|
||||
@change="toggleSelectAll"
|
||||
density="compact"
|
||||
/>
|
||||
</th>
|
||||
<th width="450">제목</th>
|
||||
<th width="150">플랫폼</th>
|
||||
<th width="150">생성일</th>
|
||||
<th width="120">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="content in paginatedContents" :key="content.id" class="cursor-pointer" @click="showDetail(content)">
|
||||
<td @click.stop>
|
||||
<v-checkbox
|
||||
v-model="selectedItems"
|
||||
:value="content.id"
|
||||
@click.stop
|
||||
density="compact"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="d-flex align-center">
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<span class="font-weight-medium text-subtitle-2 mr-2">{{ content.title }}</span>
|
||||
<v-chip
|
||||
:color="getStatusColor(content.status)"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ getStatusText(content.status) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="text-caption text-truncate grey--text" style="max-width: 400px;">
|
||||
{{ content.content }}
|
||||
</div>
|
||||
<div v-if="content.hashtags?.length" class="mt-1">
|
||||
<v-chip
|
||||
v-for="tag in content.hashtags.slice(0, 3)"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
class="mr-1"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
<span v-if="content.hashtags.length > 3" class="text-caption grey--text">
|
||||
+{{ content.hashtags.length - 3 }}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-chip
|
||||
:color="getPlatformColor(content.platform)"
|
||||
size="small"
|
||||
class="mr-3"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon class="mr-1" size="14">{{ getPlatformIcon(content.platform) }}</v-icon>
|
||||
{{ getPlatformText(content.platform) }}
|
||||
</v-chip>
|
||||
<span class="font-weight-medium">{{ content.title }}</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="mt-1">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center">
|
||||
<v-chip
|
||||
:color="getStatusColor(content.status)"
|
||||
size="x-small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ getStatusText(content.status) }}
|
||||
</v-chip>
|
||||
<span class="text-caption">{{ formatDateTime(content.createdAt) }}</span>
|
||||
<span class="text-caption ml-3">조회수: {{ formatNumber(content.views || 0) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-body-2">{{ formatDate(content.createdAt) }}</div>
|
||||
<div class="text-caption grey--text">{{ formatTime(content.createdAt) }}</div>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="confirmDelete(content)"
|
||||
@click="showDetail(content)"
|
||||
class="mr-1"
|
||||
>
|
||||
<v-icon size="16">mdi-delete-outline</v-icon>
|
||||
<v-icon size="16">mdi-eye</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="editContent(content)"
|
||||
class="mr-1"
|
||||
>
|
||||
<v-icon size="16">mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="confirmDelete(content)"
|
||||
>
|
||||
<v-icon size="16">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider v-if="index < paginatedContents.length - 1" />
|
||||
</template>
|
||||
</v-list>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</div>
|
||||
|
||||
<!-- 그리드 뷰 -->
|
||||
@ -199,6 +357,7 @@
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<v-card
|
||||
elevation="2"
|
||||
@ -410,7 +569,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useContentStore } from '@/store/content'
|
||||
|
||||
@ -434,8 +593,8 @@ const selectedItems = ref([])
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(20)
|
||||
|
||||
// 콘텐츠 타입 필터 (새로운 방식)
|
||||
const selectedContentTypes = ref(['all'])
|
||||
// 콘텐츠 타입 필터 (단순한 방식으로 변경)
|
||||
const selectedContentType = ref('all')
|
||||
|
||||
// 기존 필터 상태
|
||||
const filters = ref({
|
||||
@ -478,6 +637,9 @@ const sortOptions = [
|
||||
{ title: '조회수순', value: 'views' }
|
||||
]
|
||||
|
||||
// 콘텐츠 타입 옵션 제거 (더 이상 필요 없음)
|
||||
// const contentTypeOptions = [...]
|
||||
|
||||
// 계산된 속성
|
||||
const filteredContents = computed(() => {
|
||||
let contents = contentStore.contents || []
|
||||
@ -494,10 +656,10 @@ const filteredContents = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
// 콘텐츠 타입 필터링
|
||||
if (!selectedContentTypes.value.includes('all')) {
|
||||
// 콘텐츠 타입 필터링 (수정된 로직)
|
||||
if (selectedContentType.value && selectedContentType.value !== 'all') {
|
||||
contents = contents.filter(content =>
|
||||
selectedContentTypes.value.includes(content.platform)
|
||||
content.platform === selectedContentType.value
|
||||
)
|
||||
}
|
||||
|
||||
@ -575,21 +737,8 @@ const getTypeCount = (type) => {
|
||||
return contentStore.contents?.filter(content => content.platform === type).length || 0
|
||||
}
|
||||
|
||||
// 필터 메서드
|
||||
// 필터 메서드 (수정된 로직)
|
||||
const applyContentTypeFilter = () => {
|
||||
// 전체 선택시 다른 필터 해제
|
||||
if (selectedContentTypes.value.includes('all')) {
|
||||
selectedContentTypes.value = ['all']
|
||||
} else {
|
||||
// 다른 필터 선택시 전체 해제
|
||||
selectedContentTypes.value = selectedContentTypes.value.filter(type => type !== 'all')
|
||||
|
||||
// 아무것도 선택되지 않으면 전체 선택
|
||||
if (selectedContentTypes.value.length === 0) {
|
||||
selectedContentTypes.value = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
@ -602,7 +751,7 @@ const applySorting = () => {
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
selectedContentTypes.value = ['all']
|
||||
selectedContentType.value = 'all' // 전체 선택
|
||||
searchQuery.value = ''
|
||||
filters.value = {
|
||||
published: false,
|
||||
@ -649,6 +798,14 @@ const showEditMode = () => {
|
||||
isEditMode.value = true
|
||||
}
|
||||
|
||||
// 편집 관련 메서드
|
||||
const editContent = (content) => {
|
||||
selectedContent.value = content
|
||||
editingContent.value = { ...content }
|
||||
isEditMode.value = true
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
editingContent.value = null
|
||||
isEditMode.value = false
|
||||
@ -726,6 +883,25 @@ const getStatusColor = (status) => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateTime) => {
|
||||
if (!dateTime) return '-'
|
||||
const date = new Date(dateTime)
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateTime) => {
|
||||
if (!dateTime) return '-'
|
||||
const date = new Date(dateTime)
|
||||
return date.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime) => {
|
||||
if (!dateTime) return '-'
|
||||
const date = new Date(dateTime)
|
||||
@ -758,6 +934,11 @@ const showErrorMessage = (message) => {
|
||||
showError.value = true
|
||||
}
|
||||
|
||||
// selectedItems 변화 감지
|
||||
watch(selectedItems, (newVal) => {
|
||||
selectAll.value = newVal.length === paginatedContents.value.length && paginatedContents.value.length > 0
|
||||
}, { deep: true })
|
||||
|
||||
// 라이프사이클
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
@ -772,6 +953,48 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 테이블 스타일 - 개선된 Border */
|
||||
.v-table {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v-table thead th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.v-table thead th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.v-table tbody td {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding: 12px 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.v-table tbody td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.v-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.v-table tbody tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v-chip-group {
|
||||
max-width: 100%;
|
||||
}
|
||||
@ -790,6 +1013,36 @@ onMounted(async () => {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.sticky-sidebar {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
/* Desktop 레이아웃 최적화 */
|
||||
@media (min-width: 1264px) {
|
||||
.v-col-lg-3 {
|
||||
flex: 0 0 20%;
|
||||
max-width: 20%;
|
||||
}
|
||||
|
||||
.v-col-lg-9 {
|
||||
flex: 0 0 80%;
|
||||
max-width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) and (max-width: 1263px) {
|
||||
.v-col-md-4 {
|
||||
flex: 0 0 25%;
|
||||
max-width: 25%;
|
||||
}
|
||||
|
||||
.v-col-md-8 {
|
||||
flex: 0 0 75%;
|
||||
max-width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.d-flex.align-center.justify-space-between.flex-wrap > div {
|
||||
@ -800,5 +1053,9 @@ onMounted(async () => {
|
||||
.d-flex.align-center.justify-space-between.flex-wrap > div:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sticky-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,657 +0,0 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-4">
|
||||
<!-- 페이지 헤더 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<h1 class="text-h4 font-weight-bold mb-2">메뉴 관리</h1>
|
||||
<p class="text-grey">매장의 메뉴를 등록하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
메뉴 추가
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 검색 및 필터 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="메뉴 검색"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="selectedCategory"
|
||||
label="카테고리"
|
||||
variant="outlined"
|
||||
:items="categories"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="sortBy"
|
||||
label="정렬"
|
||||
variant="outlined"
|
||||
:items="sortOptions"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 메뉴가 없는 경우 -->
|
||||
<div v-if="filteredMenus.length === 0 && !menuStore.loading">
|
||||
<v-row justify="center">
|
||||
<v-col cols="12" md="8">
|
||||
<v-card class="text-center pa-8" elevation="4">
|
||||
<v-img
|
||||
src="/images/menu-placeholder.png"
|
||||
max-width="200"
|
||||
class="mx-auto mb-4"
|
||||
/>
|
||||
<h2 class="text-h5 font-weight-bold mb-3">첫 메뉴를 등록해보세요!</h2>
|
||||
<p class="text-grey mb-4">
|
||||
메뉴를 등록하면 AI가 더 정확한 마케팅 콘텐츠를 생성할 수 있습니다
|
||||
</p>
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
prepend-icon="mdi-food-apple"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
메뉴 등록하기
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 목록 -->
|
||||
<div v-else>
|
||||
<!-- 메뉴 통계 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<h3 class="text-h4 font-weight-bold text-primary">{{ menuStore.totalCount }}</h3>
|
||||
<p class="text-caption text-grey">전체 메뉴</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<h3 class="text-h4 font-weight-bold text-success">{{ availableMenuCount }}</h3>
|
||||
<p class="text-caption text-grey">판매중</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<h3 class="text-h4 font-weight-bold text-info">{{ categoriesCount }}</h3>
|
||||
<p class="text-caption text-grey">카테고리</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card elevation="2" class="text-center pa-4">
|
||||
<h3 class="text-h4 font-weight-bold text-warning">{{ formatCurrency(averagePrice) }}</h3>
|
||||
<p class="text-caption text-grey">평균 가격</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 메뉴 그리드 -->
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="menu in filteredMenus"
|
||||
:key="menu.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<v-card elevation="2" class="h-100">
|
||||
<!-- 메뉴 이미지 -->
|
||||
<v-img
|
||||
:src="menu.imageUrl || '/images/menu-placeholder.png'"
|
||||
:alt="menu.menuName"
|
||||
height="200"
|
||||
cover
|
||||
>
|
||||
<div class="d-flex pa-2">
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
:color="menu.available ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="elevated"
|
||||
>
|
||||
{{ menu.available ? '판매중' : '품절' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-img>
|
||||
|
||||
<!-- 메뉴 정보 -->
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<v-chip
|
||||
:color="getCategoryColor(menu.category)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ menu.category }}
|
||||
</v-chip>
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="editMenu(menu)"
|
||||
>
|
||||
<v-icon size="20">mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="confirmDelete(menu)"
|
||||
>
|
||||
<v-icon size="20">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-h6 font-weight-bold mb-2">{{ menu.menuName }}</h3>
|
||||
<p class="text-body-2 text-grey mb-3" style="min-height: 40px;">
|
||||
{{ menu.description || '메뉴 설명이 없습니다' }}
|
||||
</p>
|
||||
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span class="text-h6 font-weight-bold text-primary">
|
||||
{{ formatCurrency(menu.price) }}
|
||||
</span>
|
||||
<v-rating
|
||||
:model-value="menu.rating || 0"
|
||||
readonly
|
||||
size="small"
|
||||
color="warning"
|
||||
density="compact"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴 등록/수정 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showMenuDialog"
|
||||
max-width="600"
|
||||
persistent
|
||||
scrollable
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="pa-4">
|
||||
<span class="text-h6">{{ editMode ? '메뉴 수정' : '메뉴 등록' }}</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon
|
||||
@click="closeMenuDialog"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text class="pa-6" style="max-height: 500px;">
|
||||
<v-form ref="menuForm" v-model="menuFormValid">
|
||||
<!-- 메뉴 이미지 -->
|
||||
<div class="text-center mb-6">
|
||||
<v-img
|
||||
:src="menuFormData.imageUrl || '/images/menu-placeholder.png'"
|
||||
:alt="menuFormData.menuName"
|
||||
max-width="200"
|
||||
max-height="150"
|
||||
class="mx-auto mb-3 rounded"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-camera"
|
||||
@click="selectMenuImage"
|
||||
>
|
||||
이미지 선택
|
||||
</v-btn>
|
||||
<input
|
||||
ref="menuImageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
@change="handleMenuImageUpload"
|
||||
>
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" sm="8">
|
||||
<v-text-field
|
||||
v-model="menuFormData.menuName"
|
||||
label="메뉴명 *"
|
||||
variant="outlined"
|
||||
:rules="[v => !!v || '메뉴명을 입력해주세요']"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="menuFormData.price"
|
||||
label="가격 *"
|
||||
variant="outlined"
|
||||
type="number"
|
||||
prefix="₩"
|
||||
:rules="priceRules"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-combobox
|
||||
v-model="menuFormData.category"
|
||||
label="카테고리 *"
|
||||
variant="outlined"
|
||||
:items="categories"
|
||||
:rules="[v => !!v || '카테고리를 선택해주세요']"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch
|
||||
v-model="menuFormData.available"
|
||||
label="판매 가능"
|
||||
color="primary"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="menuFormData.description"
|
||||
label="메뉴 설명"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
counter="200"
|
||||
:rules="[v => !v || v.length <= 200 || '200자 이내로 입력해주세요']"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model="menuFormData.ingredients"
|
||||
label="주요 재료"
|
||||
variant="outlined"
|
||||
hint="쉼표로 구분하여 입력"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-select
|
||||
v-model="menuFormData.spicyLevel"
|
||||
label="매운맛 정도"
|
||||
variant="outlined"
|
||||
:items="spicyLevels"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="menuFormData.calories"
|
||||
label="칼로리"
|
||||
variant="outlined"
|
||||
type="number"
|
||||
suffix="kcal"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch
|
||||
v-model="menuFormData.recommended"
|
||||
label="추천 메뉴"
|
||||
color="primary"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="text"
|
||||
@click="closeMenuDialog"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
:disabled="!menuFormValid"
|
||||
@click="saveMenu"
|
||||
>
|
||||
{{ editMode ? '수정하기' : '등록하기' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">메뉴 삭제</v-card-title>
|
||||
<v-card-text>
|
||||
<p>정말로 <strong>{{ deleteTarget?.menuName }}</strong> 메뉴를 삭제하시겠습니까?</p>
|
||||
<v-alert type="warning" variant="tonal" class="mt-3">
|
||||
삭제된 메뉴는 복구할 수 없습니다.
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey"
|
||||
variant="text"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
:loading="deleting"
|
||||
@click="deleteMenu"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<v-overlay v-if="menuStore.loading" class="align-center justify-center">
|
||||
<v-progress-circular
|
||||
color="primary"
|
||||
indeterminate
|
||||
size="64"
|
||||
/>
|
||||
</v-overlay>
|
||||
|
||||
<!-- 스낵바 -->
|
||||
<v-snackbar
|
||||
v-model="snackbar.show"
|
||||
:color="snackbar.color"
|
||||
:timeout="3000"
|
||||
location="top"
|
||||
>
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useMenuStore, useAppStore } from '@/store/index'
|
||||
import { formatCurrency } from '@/utils/formatters'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 반응형 데이터
|
||||
const searchQuery = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const sortBy = ref('name')
|
||||
const showMenuDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editMode = ref(false)
|
||||
const menuFormValid = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
const menuImageInput = ref(null)
|
||||
|
||||
const snackbar = reactive({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// 메뉴 폼 데이터
|
||||
const menuFormData = reactive({
|
||||
menuName: '',
|
||||
price: 0,
|
||||
category: '',
|
||||
description: '',
|
||||
ingredients: '',
|
||||
spicyLevel: '보통',
|
||||
calories: 0,
|
||||
available: true,
|
||||
recommended: false,
|
||||
imageUrl: ''
|
||||
})
|
||||
|
||||
// 옵션 데이터
|
||||
const categories = computed(() => {
|
||||
const menuCategories = menuStore.menus.map(menu => menu.category)
|
||||
const defaultCategories = ['면류', '튀김류', '음료', '안주', '디저트']
|
||||
return [...new Set([...defaultCategories, ...menuCategories])]
|
||||
})
|
||||
|
||||
const sortOptions = [
|
||||
{ title: '이름순', value: 'name' },
|
||||
{ title: '가격 낮은순', value: 'price_asc' },
|
||||
{ title: '가격 높은순', value: 'price_desc' },
|
||||
{ title: '추천순', value: 'recommended' }
|
||||
]
|
||||
|
||||
const spicyLevels = ['안매움', '조금매움', '보통', '매움', '아주매움']
|
||||
|
||||
// 유효성 검사 규칙
|
||||
const priceRules = [
|
||||
v => !!v || '가격을 입력해주세요',
|
||||
v => v > 0 || '0원보다 큰 가격을 입력해주세요'
|
||||
]
|
||||
|
||||
// 컴퓨티드 속성
|
||||
const filteredMenus = computed(() => {
|
||||
let filtered = menuStore.menus
|
||||
|
||||
// 검색 필터
|
||||
if (searchQuery.value) {
|
||||
filtered = filtered.filter(menu =>
|
||||
menu.menuName.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (selectedCategory.value) {
|
||||
filtered = filtered.filter(menu => menu.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
// 정렬
|
||||
switch (sortBy.value) {
|
||||
case 'name':
|
||||
filtered.sort((a, b) => a.menuName.localeCompare(b.menuName))
|
||||
break
|
||||
case 'price_asc':
|
||||
filtered.sort((a, b) => a.price - b.price)
|
||||
break
|
||||
case 'price_desc':
|
||||
filtered.sort((a, b) => b.price - a.price)
|
||||
break
|
||||
case 'recommended':
|
||||
filtered.sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0))
|
||||
break
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const availableMenuCount = computed(() =>
|
||||
menuStore.menus.filter(menu => menu.available).length
|
||||
)
|
||||
|
||||
const categoriesCount = computed(() =>
|
||||
new Set(menuStore.menus.map(menu => menu.category)).size
|
||||
)
|
||||
|
||||
const averagePrice = computed(() => {
|
||||
if (menuStore.menus.length === 0) return 0
|
||||
const total = menuStore.menus.reduce((sum, menu) => sum + menu.price, 0)
|
||||
return Math.round(total / menuStore.menus.length)
|
||||
})
|
||||
|
||||
// 메서드
|
||||
const getCategoryColor = (category) => {
|
||||
const colors = {
|
||||
'면류': 'orange',
|
||||
'튀김류': 'red',
|
||||
'음료': 'blue',
|
||||
'안주': 'purple',
|
||||
'디저트': 'pink'
|
||||
}
|
||||
return colors[category] || 'grey'
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
editMode.value = false
|
||||
resetMenuForm()
|
||||
showMenuDialog.value = true
|
||||
}
|
||||
|
||||
const editMenu = (menu) => {
|
||||
editMode.value = true
|
||||
Object.assign(menuFormData, menu)
|
||||
showMenuDialog.value = true
|
||||
}
|
||||
|
||||
const closeMenuDialog = () => {
|
||||
showMenuDialog.value = false
|
||||
resetMenuForm()
|
||||
}
|
||||
|
||||
const resetMenuForm = () => {
|
||||
Object.assign(menuFormData, {
|
||||
menuName: '',
|
||||
price: 0,
|
||||
category: '',
|
||||
description: '',
|
||||
ingredients: '',
|
||||
spicyLevel: '보통',
|
||||
calories: 0,
|
||||
available: true,
|
||||
recommended: false,
|
||||
imageUrl: ''
|
||||
})
|
||||
}
|
||||
|
||||
const selectMenuImage = () => {
|
||||
menuImageInput.value?.click()
|
||||
}
|
||||
|
||||
const handleMenuImageUpload = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
menuFormData.imageUrl = e.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
const saveMenu = async () => {
|
||||
if (!menuFormValid.value) return
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
|
||||
if (editMode.value) {
|
||||
await menuStore.updateMenu(menuFormData.id, menuFormData)
|
||||
snackbar.message = '메뉴가 수정되었습니다'
|
||||
} else {
|
||||
await menuStore.createMenu(menuFormData)
|
||||
snackbar.message = '메뉴가 등록되었습니다'
|
||||
}
|
||||
|
||||
snackbar.color = 'success'
|
||||
snackbar.show = true
|
||||
closeMenuDialog()
|
||||
} catch (error) {
|
||||
snackbar.message = error.response?.data?.message || '저장 중 오류가 발생했습니다'
|
||||
snackbar.color = 'error'
|
||||
snackbar.show = true
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (menu) => {
|
||||
deleteTarget.value = menu
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const deleteMenu = async () => {
|
||||
if (!deleteTarget.value) return
|
||||
|
||||
try {
|
||||
deleting.value = true
|
||||
await menuStore.deleteMenu(deleteTarget.value.id)
|
||||
|
||||
snackbar.message = '메뉴가 삭제되었습니다'
|
||||
snackbar.color = 'success'
|
||||
snackbar.show = true
|
||||
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
} catch (error) {
|
||||
snackbar.message = error.response?.data?.message || '삭제 중 오류가 발생했습니다'
|
||||
snackbar.color = 'error'
|
||||
snackbar.show = true
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 라이프사이클
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await menuStore.fetchMenus()
|
||||
} catch (error) {
|
||||
console.error('메뉴 목록 로드 실패:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.text-h4 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -586,13 +586,13 @@
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- SNS 정보 -->
|
||||
<!-- SNS 정보 섹션 -->
|
||||
<div class="form-section">
|
||||
<h3 class="text-h6 font-weight-bold mb-4">SNS 계정 정보</h3>
|
||||
|
||||
<v-row>
|
||||
<!-- 인스타그램 -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon color="purple" class="mr-2">mdi-instagram</v-icon>
|
||||
<span class="text-subtitle-2 font-weight-medium">인스타그램</span>
|
||||
@ -607,42 +607,44 @@
|
||||
prepend-inner-icon="mdi-at"
|
||||
hide-details="auto"
|
||||
class="flex-grow-1"
|
||||
style="min-width: 300px;"
|
||||
/>
|
||||
<v-btn
|
||||
color="purple"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:loading="snsCheckLoading.instagram"
|
||||
@click="checkSnsConnection('instagram')"
|
||||
style="min-width: 80px; flex-shrink: 0;"
|
||||
>
|
||||
연동 확인
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
|
||||
<!-- 네이버 블로그 -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon color="green" class="mr-2">mdi-blogger</v-icon>
|
||||
<v-icon color="green" class="mr-2">mdi-web</v-icon>
|
||||
<span class="text-subtitle-2 font-weight-medium">네이버 블로그</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 align-center">
|
||||
<v-text-field
|
||||
v-model="formData.blogUrl"
|
||||
placeholder="blog.naver.com/계정명"
|
||||
placeholder="blog.naver.com/계정명 형식으로 입력"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-web"
|
||||
hide-details="auto"
|
||||
class="flex-grow-1"
|
||||
style="min-width: 300px;"
|
||||
/>
|
||||
<v-btn
|
||||
color="green"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:loading="snsCheckLoading.naver_blog"
|
||||
@click="checkSnsConnection('naver_blog')"
|
||||
:loading="snsCheckLoading.blog"
|
||||
@click="checkSnsConnection('blog')"
|
||||
style="min-width: 80px; flex-shrink: 0;"
|
||||
>
|
||||
연동 확인
|
||||
</v-btn>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user