캐릭터 챗봇 #74
|
@ -218,4 +218,7 @@ $RECYCLE.BIN/
|
|||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
.kiro/
|
||||
.junie/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows
|
||||
|
|
|
@ -0,0 +1,277 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
// 캐릭터 리스트
|
||||
async function getCharacterList(page = 1, size = 20) {
|
||||
return Vue.axios.get('/admin/chat/character/list', {
|
||||
params: { page: page - 1, size }
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 검색
|
||||
async function searchCharacters(searchTerm, page = 1, size = 20) {
|
||||
return Vue.axios.get('/admin/chat/banner/search-character', {
|
||||
params: { searchTerm, page: page - 1, size }
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 상세 조회
|
||||
async function getCharacter(id) {
|
||||
return Vue.axios.get(`/admin/chat/character/${id}`)
|
||||
}
|
||||
|
||||
// 내부 헬퍼: 빈 문자열을 null로 변환
|
||||
function toNullIfBlank(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() === '' ? null : value;
|
||||
}
|
||||
return value === '' ? null : value;
|
||||
}
|
||||
|
||||
// 캐릭터 등록
|
||||
async function createCharacter(characterData) {
|
||||
const formData = new FormData()
|
||||
|
||||
// 이미지만 FormData에 추가
|
||||
if (characterData.image) formData.append('image', characterData.image)
|
||||
|
||||
// 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가
|
||||
const requestData = {
|
||||
name: toNullIfBlank(characterData.name),
|
||||
systemPrompt: toNullIfBlank(characterData.systemPrompt),
|
||||
description: toNullIfBlank(characterData.description),
|
||||
age: toNullIfBlank(characterData.age),
|
||||
gender: toNullIfBlank(characterData.gender),
|
||||
mbti: toNullIfBlank(characterData.mbti),
|
||||
characterType: toNullIfBlank(characterData.type),
|
||||
originalTitle: toNullIfBlank(characterData.originalTitle),
|
||||
originalLink: toNullIfBlank(characterData.originalLink),
|
||||
speechPattern: toNullIfBlank(characterData.speechPattern),
|
||||
speechStyle: toNullIfBlank(characterData.speechStyle),
|
||||
appearance: toNullIfBlank(characterData.appearance),
|
||||
tags: characterData.tags || [],
|
||||
hobbies: characterData.hobbies || [],
|
||||
values: characterData.values || [],
|
||||
goals: characterData.goals || [],
|
||||
relationships: characterData.relationships || [],
|
||||
personalities: characterData.personalities || [],
|
||||
backgrounds: characterData.backgrounds || [],
|
||||
memories: characterData.memories || []
|
||||
}
|
||||
|
||||
formData.append('request', JSON.stringify(requestData))
|
||||
|
||||
return Vue.axios.post('/admin/chat/character/register', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 수정
|
||||
async function updateCharacter(characterData, image = null) {
|
||||
const formData = new FormData()
|
||||
|
||||
// 이미지가 있는 경우에만 FormData에 추가
|
||||
if (image) formData.append('image', image)
|
||||
|
||||
// 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환)
|
||||
// characterData는 이미 변경된 필드만 포함하고 있음
|
||||
const processed = {}
|
||||
Object.keys(characterData).forEach(key => {
|
||||
const value = characterData[key]
|
||||
if (typeof value === 'string' || value === '') {
|
||||
processed[key] = toNullIfBlank(value)
|
||||
} else {
|
||||
processed[key] = value
|
||||
}
|
||||
})
|
||||
formData.append('request', JSON.stringify(processed))
|
||||
|
||||
return Vue.axios.put(`/admin/chat/character/update`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 배너 리스트 조회
|
||||
async function getCharacterBannerList(page = 1, size = 20) {
|
||||
return Vue.axios.get('/admin/chat/banner/list', {
|
||||
params: { page: page - 1, size }
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 배너 등록
|
||||
async function createCharacterBanner(bannerData) {
|
||||
const formData = new FormData()
|
||||
|
||||
// 이미지 FormData에 추가
|
||||
if (bannerData.image) formData.append('image', bannerData.image)
|
||||
|
||||
// 캐릭터 ID를 JSON 문자열로 변환하여 request 필드에 추가
|
||||
const requestData = {
|
||||
characterId: bannerData.characterId
|
||||
}
|
||||
|
||||
formData.append('request', JSON.stringify(requestData))
|
||||
|
||||
return Vue.axios.post('/admin/chat/banner/register', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 배너 수정
|
||||
async function updateCharacterBanner(bannerData) {
|
||||
const formData = new FormData()
|
||||
|
||||
// 이미지가 있는 경우에만 FormData에 추가
|
||||
if (bannerData.image) formData.append('image', bannerData.image)
|
||||
|
||||
// 캐릭터 ID와 배너 ID를 JSON 문자열로 변환하여 request 필드에 추가
|
||||
const requestData = {
|
||||
characterId: bannerData.characterId,
|
||||
bannerId: bannerData.bannerId
|
||||
}
|
||||
|
||||
formData.append('request', JSON.stringify(requestData))
|
||||
|
||||
return Vue.axios.put('/admin/chat/banner/update', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 배너 삭제
|
||||
async function deleteCharacterBanner(bannerId) {
|
||||
return Vue.axios.delete(`/admin/chat/banner/${bannerId}`)
|
||||
}
|
||||
|
||||
// 캐릭터 배너 순서 변경
|
||||
async function updateCharacterBannerOrder(bannerIds) {
|
||||
return Vue.axios.put('/admin/chat/banner/orders', {ids: bannerIds})
|
||||
}
|
||||
|
||||
// 캐릭터 이미지 리스트
|
||||
async function getCharacterImageList(characterId, page = 1, size = 20) {
|
||||
return Vue.axios.get('/admin/chat/character/image/list', {
|
||||
params: { characterId, page: page - 1, size }
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 이미지 상세
|
||||
async function getCharacterImage(imageId) {
|
||||
return Vue.axios.get(`/admin/chat/character/image/${imageId}`)
|
||||
}
|
||||
|
||||
// 캐릭터 이미지 등록
|
||||
async function createCharacterImage(imageData) {
|
||||
const formData = new FormData()
|
||||
if (imageData.image) formData.append('image', imageData.image)
|
||||
const requestData = {
|
||||
characterId: imageData.characterId,
|
||||
imagePriceCan: imageData.imagePriceCan,
|
||||
messagePriceCan: imageData.messagePriceCan,
|
||||
isAdult: imageData.isAdult,
|
||||
triggers: imageData.triggers || []
|
||||
}
|
||||
formData.append('request', JSON.stringify(requestData))
|
||||
return Vue.axios.post('/admin/chat/character/image/register', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 캐릭터 이미지 수정 (트리거만 수정)
|
||||
async function updateCharacterImage(imageData) {
|
||||
const imageId = imageData.imageId
|
||||
const payload = { triggers: imageData.triggers || [] }
|
||||
return Vue.axios.put(`/admin/chat/character/image/${imageId}/triggers`, payload)
|
||||
}
|
||||
|
||||
// 캐릭터 이미지 삭제
|
||||
async function deleteCharacterImage(imageId) {
|
||||
return Vue.axios.delete(`/admin/chat/character/image/${imageId}`)
|
||||
}
|
||||
|
||||
// 캐릭터 이미지 순서 변경
|
||||
async function updateCharacterImageOrder(characterId, imageIds) {
|
||||
return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: imageIds })
|
||||
}
|
||||
|
||||
// 캐릭터 큐레이션 목록
|
||||
async function getCharacterCurationList() {
|
||||
return Vue.axios.get('/admin/chat/character/curation/list')
|
||||
}
|
||||
|
||||
// 캐릭터 큐레이션 등록
|
||||
async function createCharacterCuration({ title, isAdult, isActive }) {
|
||||
return Vue.axios.post('/admin/chat/character/curation/register', { title, isAdult, isActive })
|
||||
}
|
||||
|
||||
// 캐릭터 큐레이션 수정
|
||||
// payload: { id: Long, title?, isAdult?, isActive? }
|
||||
async function updateCharacterCuration(payload) {
|
||||
return Vue.axios.put('/admin/chat/character/curation/update', payload)
|
||||
}
|
||||
|
||||
// 캐릭터 큐레이션 삭제
|
||||
async function deleteCharacterCuration(curationId) {
|
||||
return Vue.axios.delete(`/admin/chat/character/curation/${curationId}`)
|
||||
}
|
||||
|
||||
// 캐릭터 큐레이션 정렬 순서 변경
|
||||
async function updateCharacterCurationOrder(ids) {
|
||||
return Vue.axios.put('/admin/chat/character/curation/reorder', { ids })
|
||||
}
|
||||
|
||||
// 큐레이션에 캐릭터 등록 (다중 등록)
|
||||
// characterIds: Array<Long>
|
||||
async function addCharacterToCuration(curationId, characterIds) {
|
||||
return Vue.axios.post(`/admin/chat/character/curation/${curationId}/characters`, { characterIds })
|
||||
}
|
||||
|
||||
// 큐레이션에서 캐릭터 삭제
|
||||
async function removeCharacterFromCuration(curationId, characterId) {
|
||||
return Vue.axios.delete(`/admin/chat/character/curation/${curationId}/characters/${characterId}`)
|
||||
}
|
||||
|
||||
// 큐레이션 내 캐릭터 정렬 순서 변경
|
||||
async function updateCurationCharactersOrder(curationId, characterIds) {
|
||||
return Vue.axios.put(`/admin/chat/character/curation/${curationId}/characters/reorder`, { characterIds })
|
||||
}
|
||||
|
||||
// 큐레이션 캐릭터 목록 조회 (가정된 엔드포인트)
|
||||
async function getCharactersInCuration(curationId) {
|
||||
return Vue.axios.get(`/admin/chat/character/curation/${curationId}/characters`)
|
||||
}
|
||||
|
||||
export {
|
||||
getCharacterList,
|
||||
searchCharacters,
|
||||
getCharacter,
|
||||
createCharacter,
|
||||
updateCharacter,
|
||||
getCharacterBannerList,
|
||||
createCharacterBanner,
|
||||
updateCharacterBanner,
|
||||
deleteCharacterBanner,
|
||||
updateCharacterBannerOrder,
|
||||
getCharacterImageList,
|
||||
getCharacterImage,
|
||||
createCharacterImage,
|
||||
updateCharacterImage,
|
||||
deleteCharacterImage,
|
||||
updateCharacterImageOrder,
|
||||
// Character Curation
|
||||
getCharacterCurationList,
|
||||
createCharacterCuration,
|
||||
updateCharacterCuration,
|
||||
deleteCharacterCuration,
|
||||
updateCharacterCurationOrder,
|
||||
addCharacterToCuration,
|
||||
removeCharacterFromCuration,
|
||||
updateCurationCharactersOrder,
|
||||
getCharactersInCuration
|
||||
}
|
|
@ -43,6 +43,7 @@
|
|||
>
|
||||
<v-list-item
|
||||
:to="childItem.route"
|
||||
:exact="childItem.route === '/character'"
|
||||
active-class="blue white--text"
|
||||
>
|
||||
<v-list-item-title>{{ childItem.title }}</v-list-item-title>
|
||||
|
@ -95,6 +96,29 @@ export default {
|
|||
let res = await api.getMenus();
|
||||
if (res.status === 200 && res.data.success === true && res.data.data.length > 0) {
|
||||
this.items = res.data.data
|
||||
|
||||
// 캐릭터 챗봇 메뉴 추가
|
||||
this.items.push({
|
||||
title: '캐릭터 챗봇',
|
||||
route: null,
|
||||
items: [
|
||||
{
|
||||
title: '배너 등록',
|
||||
route: '/character/banner',
|
||||
items: null
|
||||
},
|
||||
{
|
||||
title: '캐릭터 리스트',
|
||||
route: '/character',
|
||||
items: null
|
||||
},
|
||||
{
|
||||
title: '큐레이션',
|
||||
route: '/character/curation',
|
||||
items: null
|
||||
}
|
||||
]
|
||||
})
|
||||
} else {
|
||||
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!")
|
||||
this.logout();
|
||||
|
|
|
@ -255,6 +255,41 @@ const routes = [
|
|||
name: 'MarketingAdStatisticsView',
|
||||
component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/character',
|
||||
name: 'CharacterList',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/form',
|
||||
name: 'CharacterForm',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/banner',
|
||||
name: 'CharacterBanner',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/images',
|
||||
name: 'CharacterImageList',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageList.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/images/form',
|
||||
name: 'CharacterImageForm',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/curation',
|
||||
name: 'CharacterCuration',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCuration.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/curation/detail',
|
||||
name: 'CharacterCurationDetail',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue')
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
<v-card-text>
|
||||
지급할 캔 수: {{ can }} 캔
|
||||
</v-card-text>
|
||||
<v-card-actions v-show="!isLoading">
|
||||
<v-card-actions v-show="!is_loading">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="blue darken-1"
|
||||
|
@ -95,7 +95,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
show_confirm: false,
|
||||
isLoading: false,
|
||||
is_loading: false,
|
||||
account_id: '',
|
||||
method: '',
|
||||
can: ''
|
||||
|
@ -124,7 +124,7 @@ export default {
|
|||
return this.notifyError('캔은 숫자만 넣을 수 있습니다.')
|
||||
}
|
||||
|
||||
if (!this.isLoading) {
|
||||
if (!this.is_loading) {
|
||||
this.show_confirm = true
|
||||
}
|
||||
},
|
||||
|
@ -134,8 +134,8 @@ export default {
|
|||
},
|
||||
|
||||
async submit() {
|
||||
if (!this.isLoading) {
|
||||
this.isLoading = true
|
||||
if (!this.is_loading) {
|
||||
this.is_loading = true
|
||||
|
||||
try {
|
||||
this.show_confirm = false
|
||||
|
|
|
@ -0,0 +1,583 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-btn
|
||||
icon
|
||||
@click="goBack"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>캐릭터 배너 관리</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
dark
|
||||
@click="showAddDialog"
|
||||
>
|
||||
배너 추가
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
</v-row>
|
||||
|
||||
<!-- 로딩 표시 -->
|
||||
<v-row v-if="isLoading && banners.length === 0">
|
||||
<v-col class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 배너 그리드 -->
|
||||
<v-row>
|
||||
<draggable
|
||||
v-model="banners"
|
||||
class="row"
|
||||
style="width: 100%"
|
||||
:options="{ animation: 150 }"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<v-col
|
||||
v-for="banner in banners"
|
||||
:key="banner.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
class="banner-item"
|
||||
>
|
||||
<v-card
|
||||
class="mx-auto"
|
||||
max-width="300"
|
||||
>
|
||||
<v-img
|
||||
:src="banner.imageUrl"
|
||||
height="200"
|
||||
contain
|
||||
/>
|
||||
<v-card-text class="text-center">
|
||||
<div>{{ banner.characterName }}</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
small
|
||||
color="primary"
|
||||
@click="showEditDialog(banner)"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
@click="confirmDelete(banner)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</draggable>
|
||||
</v-row>
|
||||
|
||||
<!-- 데이터가 없을 때 표시 -->
|
||||
<v-row v-if="!isLoading && banners.length === 0">
|
||||
<v-col class="text-center">
|
||||
<p>등록된 배너가 없습니다.</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 무한 스크롤 로딩 -->
|
||||
<v-row v-if="isLoading && banners.length > 0">
|
||||
<v-col class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<!-- 배너 추가/수정 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
max-width="600px"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ isEdit ? '배너 수정' : '배너 추가' }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-file-input
|
||||
v-model="bannerForm.image"
|
||||
label="배너 이미지"
|
||||
accept="image/*"
|
||||
prepend-icon="mdi-camera"
|
||||
show-size
|
||||
truncate-length="15"
|
||||
:rules="imageRules"
|
||||
outlined
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="previewImage || (isEdit && bannerForm.imageUrl)">
|
||||
<v-col
|
||||
cols="12"
|
||||
class="text-center"
|
||||
>
|
||||
<v-img
|
||||
:src="previewImage || bannerForm.imageUrl"
|
||||
max-height="200"
|
||||
contain
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="searchKeyword"
|
||||
label="캐릭터 검색"
|
||||
outlined
|
||||
@keyup.enter="searchCharacter"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="searchResults.length > 0">
|
||||
<v-col cols="12">
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="character in searchResults"
|
||||
:key="character.id"
|
||||
@click="selectCharacter(character)"
|
||||
>
|
||||
<v-list-item-avatar>
|
||||
<v-img :src="character.imageUrl" />
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ character.name }}</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="searchPerformed && searchResults.length === 0">
|
||||
<v-col cols="12">
|
||||
<v-alert
|
||||
type="info"
|
||||
outlined
|
||||
>
|
||||
검색결과가 없습니다.
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="selectedCharacter">
|
||||
<v-col cols="12">
|
||||
<v-alert
|
||||
type="info"
|
||||
outlined
|
||||
>
|
||||
<v-row align="center">
|
||||
<v-col cols="auto">
|
||||
<v-avatar size="50">
|
||||
<v-img :src="selectedCharacter.imageUrl" />
|
||||
</v-avatar>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<div class="font-weight-medium">
|
||||
선택된 캐릭터: {{ selectedCharacter.name }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="blue darken-1"
|
||||
text
|
||||
@click="closeDialog"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="blue darken-1"
|
||||
text
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
@click="saveBanner"
|
||||
>
|
||||
저장
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDeleteDialog"
|
||||
max-width="400"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
배너 삭제
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
삭제 할까요?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="blue darken-1"
|
||||
text
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red darken-1"
|
||||
text
|
||||
:loading="isSubmitting"
|
||||
@click="deleteBanner"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getCharacterBannerList,
|
||||
createCharacterBanner,
|
||||
updateCharacterBanner,
|
||||
deleteCharacterBanner,
|
||||
updateCharacterBannerOrder,
|
||||
searchCharacters
|
||||
} from '@/api/character';
|
||||
import draggable from 'vuedraggable';
|
||||
|
||||
export default {
|
||||
name: 'CharacterBanner',
|
||||
|
||||
components: {
|
||||
draggable
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
banners: [],
|
||||
page: 1,
|
||||
hasMoreItems: true,
|
||||
showDialog: false,
|
||||
showDeleteDialog: false,
|
||||
isEdit: false,
|
||||
selectedBanner: null,
|
||||
selectedCharacter: null,
|
||||
searchKeyword: '',
|
||||
searchResults: [],
|
||||
searchPerformed: false,
|
||||
previewImage: null,
|
||||
bannerForm: {
|
||||
image: null,
|
||||
imageUrl: '',
|
||||
characterId: null,
|
||||
bannerId: null
|
||||
},
|
||||
imageRules: [
|
||||
v => !!v || this.isEdit || '이미지를 선택하세요'
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isFormValid() {
|
||||
return (this.bannerForm.image || (this.isEdit && this.bannerForm.imageUrl)) && this.selectedCharacter;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'bannerForm.image': {
|
||||
handler(newImage) {
|
||||
if (newImage) {
|
||||
this.createImagePreview(newImage);
|
||||
} else {
|
||||
this.previewImage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadBanners();
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
|
||||
methods: {
|
||||
notifyError(message) {
|
||||
this.$dialog.notify.error(message);
|
||||
},
|
||||
|
||||
notifySuccess(message) {
|
||||
this.$dialog.notify.success(message);
|
||||
},
|
||||
|
||||
goBack() {
|
||||
this.$router.push('/character');
|
||||
},
|
||||
|
||||
async loadBanners() {
|
||||
if (this.isLoading || !this.hasMoreItems) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const response = await getCharacterBannerList(this.page);
|
||||
|
||||
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||
const data = response.data.data;
|
||||
const newBanners = data.content || [];
|
||||
this.banners = [...this.banners, ...newBanners];
|
||||
|
||||
// 더 불러올 데이터가 있는지 확인
|
||||
this.hasMoreItems = newBanners.length > 0;
|
||||
this.page++;
|
||||
} else {
|
||||
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll() {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const documentHeight = document.documentElement.offsetHeight;
|
||||
|
||||
// 스크롤이 페이지 하단에 도달하면 추가 데이터 로드
|
||||
if (scrollPosition >= documentHeight - 200 && !this.isLoading && this.hasMoreItems) {
|
||||
this.loadBanners();
|
||||
}
|
||||
},
|
||||
|
||||
showAddDialog() {
|
||||
this.isEdit = false;
|
||||
this.selectedCharacter = null;
|
||||
this.bannerForm = {
|
||||
image: null,
|
||||
imageUrl: '',
|
||||
characterId: null,
|
||||
bannerId: null
|
||||
};
|
||||
this.previewImage = null;
|
||||
this.searchKeyword = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
showEditDialog(banner) {
|
||||
this.isEdit = true;
|
||||
this.selectedBanner = banner;
|
||||
this.selectedCharacter = {
|
||||
id: banner.characterId,
|
||||
name: banner.characterName,
|
||||
imageUrl: banner.characterImageUrl
|
||||
};
|
||||
this.bannerForm = {
|
||||
image: null,
|
||||
imageUrl: banner.imageUrl,
|
||||
characterId: banner.characterId,
|
||||
bannerId: banner.id
|
||||
};
|
||||
this.previewImage = null;
|
||||
this.searchKeyword = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
this.selectedCharacter = null;
|
||||
this.bannerForm = {
|
||||
image: null,
|
||||
imageUrl: '',
|
||||
characterId: null,
|
||||
bannerId: null
|
||||
};
|
||||
this.previewImage = null;
|
||||
this.searchKeyword = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
},
|
||||
|
||||
confirmDelete(banner) {
|
||||
this.selectedBanner = banner;
|
||||
this.showDeleteDialog = true;
|
||||
},
|
||||
|
||||
createImagePreview(file) {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.previewImage = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
async searchCharacter() {
|
||||
if (!this.searchKeyword || this.searchKeyword.length < 2) {
|
||||
this.notifyError('검색어를 2글자 이상 입력하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await searchCharacters(this.searchKeyword);
|
||||
|
||||
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||
const data = response.data.data;
|
||||
this.searchResults = data.content || [];
|
||||
this.searchPerformed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('캐릭터 검색 오류:', error);
|
||||
this.notifyError('캐릭터 검색에 실패했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
selectCharacter(character) {
|
||||
this.selectedCharacter = character;
|
||||
this.bannerForm.characterId = character.id;
|
||||
this.searchResults = [];
|
||||
},
|
||||
|
||||
async saveBanner() {
|
||||
if (!this.isFormValid || this.isSubmitting) return;
|
||||
|
||||
this.isSubmitting = true;
|
||||
|
||||
try {
|
||||
if (this.isEdit) {
|
||||
// 배너 수정
|
||||
const response = await updateCharacterBanner({
|
||||
image: this.bannerForm.image,
|
||||
characterId: this.selectedCharacter.id,
|
||||
bannerId: this.bannerForm.bannerId
|
||||
});
|
||||
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||
this.notifySuccess('배너가 수정되었습니다.');
|
||||
} else {
|
||||
this.notifyError('배너 수정을 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
// 배너 추가
|
||||
const response = await createCharacterBanner({
|
||||
image: this.bannerForm.image,
|
||||
characterId: this.selectedCharacter.id
|
||||
});
|
||||
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||
this.notifySuccess('배너가 추가되었습니다.');
|
||||
// 다이얼로그 닫고 배너 목록 새로고침
|
||||
this.closeDialog();
|
||||
this.refreshBanners();
|
||||
} else {
|
||||
this.notifyError('배너 추가를 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('배너 저장 오류:', error);
|
||||
this.notifyError('배너 저장에 실패했습니다.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteBanner() {
|
||||
if (!this.selectedBanner || this.isSubmitting) return;
|
||||
|
||||
this.isSubmitting = true;
|
||||
|
||||
try {
|
||||
const response = await deleteCharacterBanner(this.selectedBanner.id);
|
||||
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||
this.notifySuccess('배너가 삭제되었습니다.');
|
||||
this.showDeleteDialog = false;
|
||||
this.refreshBanners();
|
||||
} else {
|
||||
this.notifyError('배너 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('배너 삭제 오류:', error);
|
||||
this.notifyError('배너 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
refreshBanners() {
|
||||
// 배너 목록 초기화 후 다시 로드
|
||||
this.banners = [];
|
||||
this.page = 1;
|
||||
this.hasMoreItems = true;
|
||||
this.loadBanners();
|
||||
},
|
||||
|
||||
async onDragEnd() {
|
||||
// 드래그 앤 드롭으로 순서 변경 후 API 호출
|
||||
try {
|
||||
const bannerIds = this.banners.map(banner => banner.id);
|
||||
const response = await updateCharacterBannerOrder(bannerIds);
|
||||
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||
this.notifySuccess('배너 순서가 변경되었습니다.');
|
||||
} else {
|
||||
this.notifyError('배너 순서 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('배너 순서 변경 오류:', error);
|
||||
this.notifyError('배너 순서 변경에 실패했습니다.');
|
||||
// 실패 시 목록 새로고침
|
||||
this.refreshBanners();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.banner-item {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.banner-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,341 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>캐릭터 큐레이션</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-row class="mb-4">
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<v-btn
|
||||
color="primary"
|
||||
dark
|
||||
@click="showWriteDialog"
|
||||
>
|
||||
큐레이션 등록
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="curations"
|
||||
:loading="isLoading"
|
||||
item-key="id"
|
||||
class="elevation-1"
|
||||
hide-default-footer
|
||||
disable-pagination
|
||||
>
|
||||
<template v-slot:body="props">
|
||||
<draggable
|
||||
v-model="props.items"
|
||||
tag="tbody"
|
||||
@end="onDragEnd(props.items)"
|
||||
>
|
||||
<tr
|
||||
v-for="item in props.items"
|
||||
:key="item.id"
|
||||
>
|
||||
<td @click="goDetail(item)">
|
||||
{{ item.title }}
|
||||
</td>
|
||||
<td @click="goDetail(item)">
|
||||
<h3 v-if="item.isAdult">
|
||||
O
|
||||
</h3>
|
||||
<h3 v-else>
|
||||
X
|
||||
</h3>
|
||||
</td>
|
||||
<td>
|
||||
<v-row>
|
||||
<v-col class="text-center">
|
||||
<v-btn
|
||||
small
|
||||
color="primary"
|
||||
:disabled="isLoading"
|
||||
@click="showModifyDialog(item)"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col class="text-center">
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
:disabled="isLoading"
|
||||
@click="confirmDelete(item)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</td>
|
||||
</tr>
|
||||
</draggable>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<!-- 등록/수정 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
max-width="600px"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<span class="headline">{{ isModify ? '큐레이션 수정' : '큐레이션 등록' }}</span>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="form.title"
|
||||
label="제목"
|
||||
outlined
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-checkbox
|
||||
v-model="form.isAdult"
|
||||
label="19금"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
@click="closeDialog"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
@click="saveCuration"
|
||||
>
|
||||
저장
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDeleteDialog"
|
||||
max-width="400px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
큐레이션 삭제
|
||||
</v-card-title>
|
||||
<v-card-text>"{{ selectedCuration && selectedCuration.title }}"을(를) 삭제하시겠습니까?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
color="red darken-1"
|
||||
:loading="isSubmitting"
|
||||
@click="deleteCuration"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable';
|
||||
import {
|
||||
getCharacterCurationList,
|
||||
createCharacterCuration,
|
||||
updateCharacterCuration,
|
||||
deleteCharacterCuration,
|
||||
updateCharacterCurationOrder
|
||||
} from '@/api/character';
|
||||
|
||||
export default {
|
||||
name: 'CharacterCuration',
|
||||
components: { draggable },
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
curations: [],
|
||||
headers: [
|
||||
{ text: '제목', align: 'center', sortable: false, value: 'title' },
|
||||
{ text: '19금', align: 'center', sortable: false, value: 'isAdult' },
|
||||
{ text: '관리', align: 'center', sortable: false, value: 'management' }
|
||||
],
|
||||
showDialog: false,
|
||||
isModify: false,
|
||||
form: { id: null, title: '', isAdult: false },
|
||||
selectedCuration: null,
|
||||
showDeleteDialog: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isFormValid() {
|
||||
return this.form.title && this.form.title.trim().length > 0;
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await this.loadCurations();
|
||||
},
|
||||
methods: {
|
||||
notifyError(message) { this.$dialog.notify.error(message); },
|
||||
notifySuccess(message) { this.$dialog.notify.success(message); },
|
||||
|
||||
async loadCurations() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const res = await getCharacterCurationList();
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.curations = res.data.data || [];
|
||||
} else {
|
||||
this.notifyError(res.data.message || '목록을 불러오지 못했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
onDragEnd(items) {
|
||||
const ids = items.map(i => i.id);
|
||||
this.updateOrders(ids);
|
||||
},
|
||||
|
||||
async updateOrders(ids) {
|
||||
try {
|
||||
const res = await updateCharacterCurationOrder(ids);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess('순서가 변경되었습니다.');
|
||||
} else {
|
||||
this.notifyError(res.data.message || '순서 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('순서 변경에 실패했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
goDetail(item) {
|
||||
this.$router.push({
|
||||
name: 'CharacterCurationDetail',
|
||||
params: { curationId: item.id, title: item.title, isAdult: item.isAdult }
|
||||
});
|
||||
},
|
||||
|
||||
showWriteDialog() {
|
||||
this.isModify = false;
|
||||
this.form = { id: null, title: '', isAdult: false };
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
showModifyDialog(item) {
|
||||
this.isModify = true;
|
||||
this.form = { id: item.id, title: item.title, isAdult: item.isAdult };
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
this.form = { id: null, title: '', isAdult: false };
|
||||
},
|
||||
|
||||
async saveCuration() {
|
||||
if (this.isSubmitting || !this.isFormValid) return;
|
||||
this.isSubmitting = true;
|
||||
try {
|
||||
if (this.isModify) {
|
||||
const payload = { id: this.form.id };
|
||||
if (this.form.title) payload.title = this.form.title;
|
||||
payload.isAdult = this.form.isAdult;
|
||||
const res = await updateCharacterCuration(payload);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess('수정되었습니다.');
|
||||
this.closeDialog();
|
||||
await this.loadCurations();
|
||||
} else {
|
||||
this.notifyError(res.data.message || '수정에 실패했습니다.');
|
||||
}
|
||||
} else {
|
||||
const res = await createCharacterCuration({
|
||||
title: this.form.title,
|
||||
isAdult: this.form.isAdult,
|
||||
isActive: true
|
||||
});
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess('등록되었습니다.');
|
||||
this.closeDialog();
|
||||
await this.loadCurations();
|
||||
} else {
|
||||
this.notifyError(res.data.message || '등록에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete(item) {
|
||||
this.selectedCuration = item;
|
||||
this.showDeleteDialog = true;
|
||||
},
|
||||
|
||||
async deleteCuration() {
|
||||
if (!this.selectedCuration) return;
|
||||
this.isSubmitting = true;
|
||||
try {
|
||||
const res = await deleteCharacterCuration(this.selectedCuration.id);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess('삭제되었습니다.');
|
||||
this.showDeleteDialog = false;
|
||||
await this.loadCurations();
|
||||
} else {
|
||||
this.notifyError(res.data.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,429 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-btn
|
||||
icon
|
||||
@click="goBack"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-row class="mb-2">
|
||||
<v-col
|
||||
cols="4"
|
||||
class="text-right"
|
||||
>
|
||||
19금 :
|
||||
</v-col>
|
||||
<v-col cols="8">
|
||||
{{ isAdult ? 'O' : 'X' }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mb-4">
|
||||
<v-col
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<v-btn
|
||||
color="primary"
|
||||
dark
|
||||
@click="openAddDialog"
|
||||
>
|
||||
캐릭터 등록
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<draggable
|
||||
v-model="characters"
|
||||
class="row"
|
||||
style="width: 100%"
|
||||
:options="{ animation: 150 }"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<v-col
|
||||
v-for="ch in characters"
|
||||
:key="ch.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-card>
|
||||
<v-img
|
||||
:src="ch.imageUrl"
|
||||
height="200"
|
||||
contain
|
||||
/>
|
||||
<v-card-text class="text-center">
|
||||
{{ ch.name }}
|
||||
</v-card-text>
|
||||
<v-card-text class="text-center">
|
||||
{{ ch.description }}
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
@click="confirmRemove(ch)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</draggable>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="isLoading && characters.length === 0">
|
||||
<v-col class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="48"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!isLoading && characters.length === 0">
|
||||
<v-col class="text-center">
|
||||
등록된 캐릭터가 없습니다.
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<!-- 등록 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showAddDialog"
|
||||
max-width="700px"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>캐릭터 등록</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="searchWord"
|
||||
label="캐릭터 검색"
|
||||
outlined
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
<v-btn
|
||||
color="primary"
|
||||
small
|
||||
class="mb-2"
|
||||
@click="search"
|
||||
>
|
||||
검색
|
||||
</v-btn>
|
||||
|
||||
<v-row v-if="searchResults.length > 0 || addList.length > 0">
|
||||
<v-col>
|
||||
검색결과
|
||||
<v-simple-table>
|
||||
<template v-slot:default>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">
|
||||
이름
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in searchResults"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
small
|
||||
color="primary"
|
||||
@click="addItem(item)"
|
||||
>
|
||||
추가
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-col>
|
||||
<v-col v-if="addList.length > 0">
|
||||
추가할 캐릭터
|
||||
<v-simple-table>
|
||||
<template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">
|
||||
이름
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in addList"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
@click="removeItem(item)"
|
||||
>
|
||||
제거
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-alert
|
||||
v-else-if="searchPerformed"
|
||||
type="info"
|
||||
outlined
|
||||
>
|
||||
검색결과가 없습니다.
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
@click="closeAddDialog"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
:disabled="addList.length === 0 || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
@click="addItemInCuration"
|
||||
>
|
||||
추가
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDeleteDialog"
|
||||
max-width="420px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
캐릭터 삭제
|
||||
</v-card-title>
|
||||
<v-card-text>"{{ targetCharacter && targetCharacter.name }}"을(를) 큐레이션에서 삭제할까요?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
color="red darken-1"
|
||||
:loading="isSubmitting"
|
||||
@click="removeTarget"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from 'vuedraggable';
|
||||
import {
|
||||
getCharactersInCuration,
|
||||
addCharacterToCuration,
|
||||
removeCharacterFromCuration,
|
||||
updateCurationCharactersOrder,
|
||||
searchCharacters
|
||||
} from '@/api/character';
|
||||
|
||||
export default {
|
||||
name: 'CharacterCurationDetail',
|
||||
components: { draggable },
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
curationId: null,
|
||||
title: '',
|
||||
isAdult: false,
|
||||
characters: [],
|
||||
showAddDialog: false,
|
||||
showDeleteDialog: false,
|
||||
targetCharacter: null,
|
||||
searchWord: '',
|
||||
searchResults: [],
|
||||
searchPerformed: false,
|
||||
addList: []
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
this.curationId = this.$route.params.curationId;
|
||||
this.title = this.$route.params.title;
|
||||
this.isAdult = this.$route.params.isAdult;
|
||||
await this.loadCharacters();
|
||||
},
|
||||
methods: {
|
||||
notifyError(message) { this.$dialog.notify.error(message); },
|
||||
notifySuccess(message) { this.$dialog.notify.success(message); },
|
||||
|
||||
goBack() { this.$router.push({ name: 'CharacterCuration' }); },
|
||||
|
||||
async loadCharacters() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const res = await getCharactersInCuration(this.curationId);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.characters = res.data.data || [];
|
||||
} else {
|
||||
this.notifyError(res.data.message || '캐릭터 목록을 불러오지 못했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('캐릭터 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openAddDialog() {
|
||||
this.showAddDialog = true;
|
||||
this.searchWord = '';
|
||||
this.searchResults = [];
|
||||
this.addList = [];
|
||||
this.searchPerformed = false;
|
||||
},
|
||||
closeAddDialog() {
|
||||
this.showAddDialog = false;
|
||||
this.searchWord = '';
|
||||
this.searchResults = [];
|
||||
this.addList = [];
|
||||
this.searchPerformed = false;
|
||||
},
|
||||
|
||||
async search() {
|
||||
if (!this.searchWord || this.searchWord.length < 2) {
|
||||
this.notifyError('검색어를 2글자 이상 입력하세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await searchCharacters(this.searchWord);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
const data = res.data.data;
|
||||
const list = data.content || [];
|
||||
const existingIds = new Set(this.characters.map(c => c.id));
|
||||
const pendingIds = new Set(this.addList.map(c => c.id));
|
||||
this.searchResults = list.filter(item => !existingIds.has(item.id) && !pendingIds.has(item.id));
|
||||
this.searchPerformed = true;
|
||||
} else {
|
||||
this.notifyError(res.data.message || '검색에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('검색에 실패했습니다.');
|
||||
}
|
||||
},
|
||||
|
||||
addItem(item) {
|
||||
// 검색결과에서 제거하고 추가 목록에 삽입 (중복 방지)
|
||||
if (!this.addList.find(t => t.id === item.id)) {
|
||||
this.addList.push(item);
|
||||
}
|
||||
this.searchResults = this.searchResults.filter(t => t.id !== item.id);
|
||||
},
|
||||
|
||||
removeItem(item) {
|
||||
this.addList = this.addList.filter(t => t.id !== item.id);
|
||||
// 제거 시 검색결과에 다시 추가
|
||||
if (!this.searchResults.find(t => t.id === item.id)) {
|
||||
this.searchResults.push(item);
|
||||
}
|
||||
},
|
||||
|
||||
async addItemInCuration() {
|
||||
if (!this.addList || this.addList.length === 0) return;
|
||||
this.isSubmitting = true;
|
||||
try {
|
||||
const ids = this.addList.map(i => i.id);
|
||||
const res = await addCharacterToCuration(this.curationId, ids);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess(`${this.addList.length}명 추가되었습니다.`);
|
||||
this.closeAddDialog();
|
||||
await this.loadCharacters();
|
||||
} else {
|
||||
this.notifyError((res.data && res.data.message) || '추가에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('추가에 실패했습니다.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmRemove(item) { this.targetCharacter = item; this.showDeleteDialog = true; },
|
||||
|
||||
async removeTarget() {
|
||||
if (!this.targetCharacter) return;
|
||||
this.isSubmitting = true;
|
||||
try {
|
||||
const res = await removeCharacterFromCuration(this.curationId, this.targetCharacter.id);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess('삭제되었습니다.');
|
||||
this.showDeleteDialog = false;
|
||||
await this.loadCharacters();
|
||||
} else {
|
||||
this.notifyError(res.data.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('삭제에 실패했습니다.');
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async onDragEnd() {
|
||||
try {
|
||||
const ids = this.characters.map(c => c.id);
|
||||
const res = await updateCurationCharactersOrder(this.curationId, ids);
|
||||
if (res.status === 200 && res.data && res.data.success === true) {
|
||||
this.notifySuccess('순서가 변경되었습니다.');
|
||||
} else {
|
||||
this.notifyError(res.data.message || '순서 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('순서 변경에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,306 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-btn
|
||||
icon
|
||||
@click="goBack"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>{{ isEdit ? '이미지 수정' : '이미지 등록' }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-card class="pa-4">
|
||||
<v-form
|
||||
ref="form"
|
||||
v-model="isFormValid"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-show="!isEdit"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-file-input
|
||||
v-if="!isEdit"
|
||||
v-model="form.image"
|
||||
label="이미지 (800x1000 비율 권장)"
|
||||
accept="image/*"
|
||||
prepend-icon="mdi-camera"
|
||||
show-size
|
||||
truncate-length="15"
|
||||
outlined
|
||||
dense
|
||||
:rules="imageRules"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="previewImage || form.imageUrl"
|
||||
cols="12"
|
||||
:md="isEdit ? 12 : 6"
|
||||
>
|
||||
<div class="text-center">
|
||||
<v-img
|
||||
:src="previewImage || form.imageUrl"
|
||||
max-height="240"
|
||||
:aspect-ratio="0.8"
|
||||
contain
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-text-field
|
||||
v-model.number="form.soloPurchasePriceCan"
|
||||
label="이미지 단독 구매 가격(캔)"
|
||||
type="number"
|
||||
min="0"
|
||||
outlined
|
||||
dense
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-text-field
|
||||
v-model.number="form.messagePurchasePriceCan"
|
||||
label="메시지에서 구매 가격(캔)"
|
||||
type="number"
|
||||
min="0"
|
||||
outlined
|
||||
dense
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-switch
|
||||
v-model="form.adult"
|
||||
label="성인 이미지 여부"
|
||||
inset
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-combobox
|
||||
v-model="triggers"
|
||||
label="트리거 단어 입력"
|
||||
multiple
|
||||
chips
|
||||
small-chips
|
||||
deletable-chips
|
||||
outlined
|
||||
dense
|
||||
:rules="triggerRules"
|
||||
@keydown.space.prevent="addTrigger"
|
||||
>
|
||||
<template v-slot:selection="{ attrs, item, select, selected }">
|
||||
<v-chip
|
||||
v-bind="attrs"
|
||||
:input-value="selected"
|
||||
close
|
||||
@click="select"
|
||||
@click:close="removeTrigger(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-combobox>
|
||||
<div class="caption grey--text text--darken-1">
|
||||
트리거를 입력하고 엔터를 누르면 추가됩니다. (20자 이내, 최소 3개, 최대 10개)
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
@click="goBack"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
:disabled="!canSubmit || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
@click="save"
|
||||
>
|
||||
저장
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createCharacterImage, updateCharacterImage, getCharacterImage } from '@/api/character'
|
||||
|
||||
export default {
|
||||
name: 'CharacterImageForm',
|
||||
data() {
|
||||
return {
|
||||
isEdit: !!this.$route.query.imageId,
|
||||
isSubmitting: false,
|
||||
isFormValid: false,
|
||||
characterId: Number(this.$route.query.characterId),
|
||||
imageId: this.$route.query.imageId ? Number(this.$route.query.imageId) : null,
|
||||
form: {
|
||||
image: null,
|
||||
imageUrl: '',
|
||||
soloPurchasePriceCan: null,
|
||||
messagePurchasePriceCan: null,
|
||||
adult: false
|
||||
},
|
||||
previewImage: null,
|
||||
triggers: [],
|
||||
triggerRules: [
|
||||
v => (v && v.length >= 3 && v.length <= 10) || '트리거는 최소 3개, 최대 10개까지 등록 가능합니다'
|
||||
],
|
||||
imageRules: [
|
||||
v => !!v || '이미지를 선택하세요'
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canSubmit() {
|
||||
const triggersValid = this.triggers && this.triggers.length >= 3 && this.triggers.length <= 10
|
||||
if (this.isEdit) return triggersValid
|
||||
return !!this.form.image && triggersValid
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'form.image'(newVal) {
|
||||
if (!this.isEdit) {
|
||||
if (newVal) this.createImagePreview(newVal)
|
||||
else this.previewImage = null
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (!this.characterId) {
|
||||
this.notifyError('캐릭터 ID가 없습니다.')
|
||||
this.goBack();
|
||||
return
|
||||
}
|
||||
if (this.isEdit && this.imageId) {
|
||||
this.loadDetail()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
notifyError(m) { this.$dialog.notify.error(m) },
|
||||
notifySuccess(m) { this.$dialog.notify.success(m) },
|
||||
goBack() {
|
||||
this.$router.push({ path: '/character/images', query: { characterId: this.characterId, name: this.$route.query.name || '' } })
|
||||
},
|
||||
createImagePreview(file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = e => { this.previewImage = e.target.result }
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
addTrigger(e) {
|
||||
const value = (e.target.value || '').trim()
|
||||
if (!value) return
|
||||
if (value.length > 20) {
|
||||
this.notifyError('트리거는 20자 이내여야 합니다.')
|
||||
return
|
||||
}
|
||||
if (this.triggers.length >= 10) {
|
||||
this.notifyError('트리거는 최대 10개까지 등록 가능합니다.')
|
||||
return
|
||||
}
|
||||
if (!this.triggers.includes(value)) this.triggers.push(value)
|
||||
e.target.value = ''
|
||||
},
|
||||
removeTrigger(item) {
|
||||
this.triggers = this.triggers.filter(t => t !== item)
|
||||
},
|
||||
async loadDetail() {
|
||||
try {
|
||||
const resp = await getCharacterImage(this.imageId)
|
||||
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||
const d = resp.data.data
|
||||
// 수정 시 트리거만 노출하며 나머지는 비활성화
|
||||
this.form.imageUrl = d.imageUrl
|
||||
this.form.soloPurchasePriceCan = d.imagePriceCan
|
||||
this.form.messagePurchasePriceCan = d.messagePriceCan
|
||||
this.form.adult = d.isAdult
|
||||
this.triggers = d.triggers || []
|
||||
} else {
|
||||
this.notifyError('이미지 정보를 불러오지 못했습니다.')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('이미지 상세 오류:', e)
|
||||
this.notifyError('이미지 정보를 불러오지 못했습니다.')
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (this.isSubmitting) return
|
||||
// 트리거 개수 검증: 최소 3개, 최대 10개
|
||||
if (!this.triggers || this.triggers.length < 3 || this.triggers.length > 10) {
|
||||
this.notifyError('트리거는 최소 3개, 최대 10개여야 합니다.')
|
||||
return
|
||||
}
|
||||
this.isSubmitting = true
|
||||
try {
|
||||
if (this.isEdit) {
|
||||
const resp = await updateCharacterImage({ imageId: this.imageId, triggers: this.triggers })
|
||||
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||
this.notifySuccess('수정되었습니다.')
|
||||
this.goBack()
|
||||
} else {
|
||||
this.notifyError('수정에 실패했습니다.')
|
||||
}
|
||||
} else {
|
||||
const resp = await createCharacterImage({
|
||||
characterId: this.characterId,
|
||||
image: this.form.image,
|
||||
imagePriceCan: this.form.soloPurchasePriceCan,
|
||||
messagePriceCan: this.form.messagePurchasePriceCan,
|
||||
isAdult: this.form.adult,
|
||||
triggers: this.triggers
|
||||
})
|
||||
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||
this.notifySuccess('등록되었습니다.')
|
||||
this.goBack()
|
||||
} else {
|
||||
this.notifyError('등록에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('이미지 저장 오류:', e)
|
||||
this.notifyError('작업 중 오류가 발생했습니다.')
|
||||
} finally {
|
||||
this.isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,325 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-btn
|
||||
icon
|
||||
@click="goBack"
|
||||
>
|
||||
<v-icon>mdi-arrow-left</v-icon>
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>캐릭터 이미지 관리</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-row class="align-center mb-4">
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<div class="subtitle-1">
|
||||
캐릭터: {{ characterName || characterId }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
class="text-right"
|
||||
>
|
||||
<v-btn
|
||||
color="primary"
|
||||
dark
|
||||
@click="goToAdd"
|
||||
>
|
||||
이미지 추가
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 로딩 -->
|
||||
<v-row v-if="isLoading && images.length === 0">
|
||||
<v-col class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="48"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 목록 -->
|
||||
<draggable
|
||||
v-if="images.length > 0"
|
||||
v-model="images"
|
||||
class="image-grid"
|
||||
:options="{ animation: 150 }"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<div
|
||||
v-for="img in images"
|
||||
:key="img.id"
|
||||
class="image-card"
|
||||
>
|
||||
<v-card>
|
||||
<div class="image-wrapper">
|
||||
<v-img
|
||||
:src="img.imageUrl"
|
||||
:aspect-ratio="0.8"
|
||||
contain
|
||||
/>
|
||||
<div
|
||||
v-if="img.isAdult"
|
||||
class="ribbon"
|
||||
>
|
||||
성인
|
||||
</div>
|
||||
</div>
|
||||
<v-card-text class="pt-2">
|
||||
<div class="price-row d-flex align-center">
|
||||
<div class="price-label">
|
||||
단독 :
|
||||
</div>
|
||||
<div class="price-value">
|
||||
{{ img.imagePriceCan }} 캔
|
||||
</div>
|
||||
</div>
|
||||
<div class="price-row d-flex align-center">
|
||||
<div class="price-label">
|
||||
메시지 :
|
||||
</div>
|
||||
<div class="price-value">
|
||||
{{ img.messagePriceCan }} 캔
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<v-chip
|
||||
v-for="(t, i) in (img.triggers || [])"
|
||||
:key="i"
|
||||
small
|
||||
class="ma-1"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ t }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
small
|
||||
color="primary"
|
||||
@click="goToEdit(img)"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
@click="confirmDelete(img)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</draggable>
|
||||
|
||||
<!-- 데이터 없음 -->
|
||||
<v-row v-if="!isLoading && images.length === 0">
|
||||
<v-col class="text-center grey--text">
|
||||
등록된 이미지가 없습니다.
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="showDeleteDialog"
|
||||
max-width="400"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
이미지 삭제
|
||||
</v-card-title>
|
||||
<v-card-text>삭제하시겠습니까?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
color="blue darken-1"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
color="red darken-1"
|
||||
:loading="isSubmitting"
|
||||
@click="deleteImage"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCharacterImageList, deleteCharacterImage, updateCharacterImageOrder } from '@/api/character'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
name: 'CharacterImageList',
|
||||
components: { draggable },
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
images: [],
|
||||
characterId: null,
|
||||
characterName: this.$route.query.name || '',
|
||||
showDeleteDialog: false,
|
||||
selectedImage: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.characterId = Number(this.$route.query.characterId)
|
||||
if (!this.characterId) {
|
||||
this.notifyError('캐릭터 ID가 없습니다.');
|
||||
this.goBack()
|
||||
return
|
||||
}
|
||||
this.loadImages()
|
||||
},
|
||||
methods: {
|
||||
notifyError(message) { this.$dialog.notify.error(message) },
|
||||
notifySuccess(message) { this.$dialog.notify.success(message) },
|
||||
goBack() { this.$router.push('/character') },
|
||||
async loadImages() {
|
||||
this.isLoading = true
|
||||
try {
|
||||
const resp = await getCharacterImageList(this.characterId, 1, 20)
|
||||
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||
const data = resp.data.data
|
||||
this.images = (data.content || data || [])
|
||||
} else {
|
||||
this.notifyError('이미지 목록을 불러오지 못했습니다.')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('이미지 목록 오류:', e)
|
||||
this.notifyError('이미지 목록 조회 중 오류가 발생했습니다.')
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
goToAdd() {
|
||||
this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, name: this.characterName } })
|
||||
},
|
||||
goToEdit(img) {
|
||||
this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, imageId: img.id, name: this.characterName } })
|
||||
},
|
||||
confirmDelete(img) {
|
||||
this.selectedImage = img
|
||||
this.showDeleteDialog = true
|
||||
},
|
||||
async deleteImage() {
|
||||
if (!this.selectedImage || this.isSubmitting) return
|
||||
this.isSubmitting = true
|
||||
try {
|
||||
const resp = await deleteCharacterImage(this.selectedImage.id)
|
||||
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||
this.notifySuccess('삭제되었습니다.')
|
||||
this.showDeleteDialog = false
|
||||
await this.loadImages()
|
||||
} else {
|
||||
this.notifyError('삭제에 실패했습니다.')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('이미지 삭제 오류:', e)
|
||||
this.notifyError('삭제 중 오류가 발생했습니다.')
|
||||
} finally {
|
||||
this.isSubmitting = false
|
||||
}
|
||||
},
|
||||
async onDragEnd() {
|
||||
try {
|
||||
const ids = this.images.map(img => img.id)
|
||||
const resp = await updateCharacterImageOrder(this.characterId, ids)
|
||||
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||
this.notifySuccess('이미지 순서가 변경되었습니다.')
|
||||
} else {
|
||||
this.notifyError('이미지 순서 변경에 실패했습니다.')
|
||||
await this.loadImages()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('이미지 순서 변경 오류:', e)
|
||||
this.notifyError('이미지 순서 변경에 실패했습니다.')
|
||||
await this.loadImages()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.image-card {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 1264px) {
|
||||
.image-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.image-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.image-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* Image wrapper for overlays */
|
||||
.image-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ribbon style for adult indicator */
|
||||
.ribbon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
background: #e53935; /* red darken-1 */
|
||||
color: #fff;
|
||||
padding: 6px 20px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
text-transform: none;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Price rows styling */
|
||||
.price-row {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.price-label {
|
||||
width: 72px; /* 긴 쪽 기준으로 라벨 고정폭 */
|
||||
text-align: left;
|
||||
color: rgba(0,0,0,0.6);
|
||||
font-weight: 700;
|
||||
}
|
||||
.price-value {
|
||||
flex: 1;
|
||||
font-weight: 700;
|
||||
color: rgba(0,0,0,0.87);
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,406 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>캐릭터 리스트</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-toolbar>
|
||||
|
||||
<br>
|
||||
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
dark
|
||||
@click="showAddDialog"
|
||||
>
|
||||
캐릭터 추가
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-simple-table class="elevation-10">
|
||||
<template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">
|
||||
ID
|
||||
</th>
|
||||
<th class="text-center">
|
||||
이미지
|
||||
</th>
|
||||
<th class="text-center">
|
||||
캐릭터명
|
||||
</th>
|
||||
<th class="text-center">
|
||||
성별
|
||||
</th>
|
||||
<th class="text-center">
|
||||
나이
|
||||
</th>
|
||||
<th class="text-center">
|
||||
캐릭터 설명
|
||||
</th>
|
||||
<th class="text-center">
|
||||
MBTI
|
||||
</th>
|
||||
<th class="text-center">
|
||||
말투
|
||||
</th>
|
||||
<th class="text-center">
|
||||
대화 스타일
|
||||
</th>
|
||||
<th class="text-center">
|
||||
태그
|
||||
</th>
|
||||
<th class="text-center">
|
||||
등록일
|
||||
</th>
|
||||
<th class="text-center">
|
||||
수정일
|
||||
</th>
|
||||
<th class="text-center">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in characters"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>{{ item.id }}</td>
|
||||
<td align="center">
|
||||
<v-img
|
||||
max-width="100"
|
||||
max-height="100"
|
||||
:src="item.imageUrl"
|
||||
class="rounded-circle"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.gender || '-' }}</td>
|
||||
<td>{{ item.age || '-' }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
small
|
||||
color="info"
|
||||
@click="showDetailDialog(item, 'description')"
|
||||
>
|
||||
보기
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>{{ item.mbti || '-' }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
small
|
||||
color="info"
|
||||
@click="showDetailDialog(item, 'speechPattern')"
|
||||
>
|
||||
보기
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn
|
||||
small
|
||||
color="info"
|
||||
@click="showDetailDialog(item, 'speechStyle')"
|
||||
>
|
||||
보기
|
||||
</v-btn>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="item.tags && item.tags.length > 0">
|
||||
<v-chip
|
||||
v-for="(tag, index) in item.tags"
|
||||
:key="index"
|
||||
small
|
||||
class="ma-1"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ tag }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>{{ item.createdAt }}</td>
|
||||
<td>{{ item.updatedAt || '-' }}</td>
|
||||
<td>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn
|
||||
small
|
||||
color="primary"
|
||||
:disabled="is_loading"
|
||||
@click="showEditDialog(item)"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
small
|
||||
color="info"
|
||||
:disabled="is_loading"
|
||||
@click="goToImageList(item)"
|
||||
>
|
||||
이미지
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
:disabled="is_loading"
|
||||
@click="deleteConfirm(item)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</v-simple-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="text-center">
|
||||
<v-col>
|
||||
<v-pagination
|
||||
v-model="page"
|
||||
:length="total_page"
|
||||
circle
|
||||
@input="next"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
|
||||
<!-- 삭제 확인 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="show_delete_confirm_dialog"
|
||||
max-width="400px"
|
||||
persistent
|
||||
>
|
||||
<v-card>
|
||||
<v-card-text />
|
||||
<v-card-text>
|
||||
"{{ selected_character.name }}"을(를) 삭제하시겠습니까?
|
||||
</v-card-text>
|
||||
<v-card-actions v-show="!is_loading">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="blue darken-1"
|
||||
text
|
||||
@click="closeDeleteDialog"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="red darken-1"
|
||||
text
|
||||
@click="deleteCharacter"
|
||||
>
|
||||
확인
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 상세 내용 다이얼로그 -->
|
||||
<v-dialog
|
||||
v-model="show_detail_dialog"
|
||||
max-width="600px"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ detail_title }}
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pt-4">
|
||||
<div style="white-space: pre-wrap;">
|
||||
{{ detail_content }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
text
|
||||
@click="closeDetailDialog"
|
||||
>
|
||||
닫기
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCharacterList, updateCharacter } from '@/api/character'
|
||||
|
||||
export default {
|
||||
name: "CharacterList",
|
||||
|
||||
data() {
|
||||
return {
|
||||
is_loading: false,
|
||||
show_delete_confirm_dialog: false,
|
||||
show_detail_dialog: false,
|
||||
detail_type: '',
|
||||
detail_content: '',
|
||||
detail_title: '',
|
||||
page: 1,
|
||||
total_page: 0,
|
||||
characters: [],
|
||||
selected_character: {}
|
||||
}
|
||||
},
|
||||
|
||||
async created() {
|
||||
await this.getCharacters()
|
||||
},
|
||||
|
||||
methods: {
|
||||
notifyError(message) {
|
||||
this.$dialog.notify.error(message)
|
||||
},
|
||||
|
||||
notifySuccess(message) {
|
||||
this.$dialog.notify.success(message)
|
||||
},
|
||||
|
||||
|
||||
showDetailDialog(item, type) {
|
||||
this.selected_character = item;
|
||||
this.detail_type = type;
|
||||
|
||||
// 타입에 따라 제목과 내용 설정
|
||||
switch(type) {
|
||||
case 'description':
|
||||
this.detail_title = '캐릭터 설명';
|
||||
this.detail_content = item.description || '내용이 없습니다.';
|
||||
break;
|
||||
case 'speechPattern':
|
||||
this.detail_title = '말투';
|
||||
this.detail_content = item.speechPattern || '내용이 없습니다.';
|
||||
break;
|
||||
case 'speechStyle':
|
||||
this.detail_title = '대화 스타일';
|
||||
this.detail_content = item.speechStyle || '내용이 없습니다.';
|
||||
break;
|
||||
default:
|
||||
this.detail_title = '';
|
||||
this.detail_content = '';
|
||||
}
|
||||
|
||||
this.show_detail_dialog = true;
|
||||
},
|
||||
|
||||
closeDetailDialog() {
|
||||
this.show_detail_dialog = false;
|
||||
this.detail_type = '';
|
||||
this.detail_content = '';
|
||||
this.detail_title = '';
|
||||
},
|
||||
|
||||
showAddDialog() {
|
||||
// 페이지로 이동
|
||||
this.$router.push('/character/form');
|
||||
},
|
||||
|
||||
goToImageList(item) {
|
||||
this.$router.push({
|
||||
path: '/character/images',
|
||||
query: { characterId: item.id, name: item.name }
|
||||
})
|
||||
},
|
||||
|
||||
showEditDialog(item) {
|
||||
// 페이지로 이동하면서 id 전달
|
||||
this.$router.push({
|
||||
path: '/character/form',
|
||||
query: { id: item.id }
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
deleteConfirm(item) {
|
||||
this.selected_character = item
|
||||
this.show_delete_confirm_dialog = true
|
||||
},
|
||||
|
||||
closeDeleteDialog() {
|
||||
this.show_delete_confirm_dialog = false
|
||||
this.selected_character = {}
|
||||
},
|
||||
|
||||
|
||||
async deleteCharacter() {
|
||||
if (this.is_loading) return;
|
||||
this.is_loading = true
|
||||
|
||||
try {
|
||||
// 삭제 대신 isActive를 false로 설정하여 비활성화
|
||||
const updateData = {
|
||||
id: this.selected_character.id,
|
||||
isActive: false
|
||||
};
|
||||
await updateCharacter(updateData);
|
||||
this.closeDeleteDialog();
|
||||
this.notifySuccess('삭제되었습니다.');
|
||||
await this.getCharacters();
|
||||
} catch (e) {
|
||||
console.error('캐릭터 삭제 오류:', e);
|
||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
} finally {
|
||||
this.is_loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async next() {
|
||||
await this.getCharacters()
|
||||
},
|
||||
|
||||
async getCharacters() {
|
||||
this.is_loading = true
|
||||
try {
|
||||
const response = await getCharacterList(this.page);
|
||||
|
||||
if (response && response.status === 200) {
|
||||
if (response.data.success === true) {
|
||||
const data = response.data.data;
|
||||
this.characters = data.content || [];
|
||||
|
||||
const total_page = Math.ceil((data.totalCount || 0) / 20);
|
||||
this.total_page = total_page <= 0 ? 1 : total_page;
|
||||
} else {
|
||||
this.notifyError('응답 데이터가 없습니다.');
|
||||
}
|
||||
} else {
|
||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('캐릭터 목록 조회 오류:', e);
|
||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
} finally {
|
||||
this.is_loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-data-table {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue