StoreManagementView.vue services/api.js edit
This commit is contained in:
parent
bfc4d600e2
commit
ccdc2c91bb
BIN
public/images/menu-placeholder.png
Normal file
BIN
public/images/menu-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@ -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__에 노출됨')
|
||||
|
||||
@ -79,10 +79,12 @@ export const useStoreStore = defineStore('store', {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ✅ ID 필드 보장
|
||||
// src/store/index.js에서 fetchMenus 부분만 수정
|
||||
|
||||
/**
|
||||
* 메뉴 목록 조회 - 실제 API 연동 (매장 ID 필요) - ✅ 이미지 필드 매핑 수정
|
||||
*/
|
||||
async fetchMenus() {
|
||||
async fetchMenus() {
|
||||
console.log('=== Store 스토어: 메뉴 목록 조회 시작 ===')
|
||||
|
||||
try {
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
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 '-'
|
||||
if (!startDate && !endDate) return '기간 미설정'
|
||||
if (!endDate) return formatDate(startDate) + ' ~'
|
||||
if (!startDate) return '~ ' + formatDate(endDate)
|
||||
return formatDate(startDate) + ' ~ ' + formatDate(endDate)
|
||||
}
|
||||
|
||||
// 라이프사이클
|
||||
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
Loading…
x
Reference in New Issue
Block a user