캐릭터 챗봇 #74
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -218,4 +218,7 @@ $RECYCLE.BIN/ | |||||||
| # Windows shortcuts | # Windows shortcuts | ||||||
| *.lnk | *.lnk | ||||||
|  |  | ||||||
|  | .kiro/ | ||||||
|  | .junie/ | ||||||
|  |  | ||||||
| # End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows | # End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows | ||||||
|   | |||||||
							
								
								
									
										277
									
								
								src/api/character.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								src/api/character.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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 |             <v-list-item | ||||||
|               :to="childItem.route" |               :to="childItem.route" | ||||||
|  |               :exact="childItem.route === '/character'" | ||||||
|               active-class="blue white--text" |               active-class="blue white--text" | ||||||
|             > |             > | ||||||
|               <v-list-item-title>{{ childItem.title }}</v-list-item-title> |               <v-list-item-title>{{ childItem.title }}</v-list-item-title> | ||||||
| @@ -95,6 +96,29 @@ export default { | |||||||
|         let res = await api.getMenus(); |         let res = await api.getMenus(); | ||||||
|         if (res.status === 200 && res.data.success === true && res.data.data.length > 0) { |         if (res.status === 200 && res.data.success === true && res.data.data.length > 0) { | ||||||
|           this.items = res.data.data |           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 { |         } else { | ||||||
|           this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") |           this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") | ||||||
|           this.logout(); |           this.logout(); | ||||||
|   | |||||||
| @@ -255,6 +255,41 @@ const routes = [ | |||||||
|                 name: 'MarketingAdStatisticsView', |                 name: 'MarketingAdStatisticsView', | ||||||
|                 component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue') |                 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> |             <v-card-text> | ||||||
|               지급할 캔 수: {{ can }} 캔 |               지급할 캔 수: {{ can }} 캔 | ||||||
|             </v-card-text> |             </v-card-text> | ||||||
|             <v-card-actions v-show="!isLoading"> |             <v-card-actions v-show="!is_loading"> | ||||||
|               <v-spacer /> |               <v-spacer /> | ||||||
|               <v-btn |               <v-btn | ||||||
|                 color="blue darken-1" |                 color="blue darken-1" | ||||||
| @@ -95,7 +95,7 @@ export default { | |||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       show_confirm: false, |       show_confirm: false, | ||||||
|       isLoading: false, |       is_loading: false, | ||||||
|       account_id: '', |       account_id: '', | ||||||
|       method: '', |       method: '', | ||||||
|       can: '' |       can: '' | ||||||
| @@ -124,7 +124,7 @@ export default { | |||||||
|         return this.notifyError('캔은 숫자만 넣을 수 있습니다.') |         return this.notifyError('캔은 숫자만 넣을 수 있습니다.') | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (!this.isLoading) { |       if (!this.is_loading) { | ||||||
|         this.show_confirm = true |         this.show_confirm = true | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
| @@ -134,8 +134,8 @@ export default { | |||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     async submit() { |     async submit() { | ||||||
|       if (!this.isLoading) { |       if (!this.is_loading) { | ||||||
|         this.isLoading = true |         this.is_loading = true | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|           this.show_confirm = false |           this.show_confirm = false | ||||||
|   | |||||||
							
								
								
									
										583
									
								
								src/views/Chat/CharacterBanner.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										583
									
								
								src/views/Chat/CharacterBanner.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										341
									
								
								src/views/Chat/CharacterCuration.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										341
									
								
								src/views/Chat/CharacterCuration.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										429
									
								
								src/views/Chat/CharacterCurationDetail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										429
									
								
								src/views/Chat/CharacterCurationDetail.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										1822
									
								
								src/views/Chat/CharacterForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1822
									
								
								src/views/Chat/CharacterForm.vue
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										306
									
								
								src/views/Chat/CharacterImageForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										306
									
								
								src/views/Chat/CharacterImageForm.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										325
									
								
								src/views/Chat/CharacterImageList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/views/Chat/CharacterImageList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										406
									
								
								src/views/Chat/CharacterList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										406
									
								
								src/views/Chat/CharacterList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
		Reference in New Issue
	
	Block a user