storeManagement contentmanagement edit

This commit is contained in:
unknown 2025-06-12 17:25:41 +09:00
parent f8c7ecc261
commit 02765ba943
3 changed files with 331 additions and 729 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>