캐릭터 챗봇 큐레이션 추가
This commit is contained in:
		| @@ -200,6 +200,53 @@ async function updateCharacterImageOrder(characterId, imageIds) { | |||||||
|   return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: 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 { | export { | ||||||
|   getCharacterList, |   getCharacterList, | ||||||
|   searchCharacters, |   searchCharacters, | ||||||
| @@ -216,5 +263,15 @@ export { | |||||||
|   createCharacterImage, |   createCharacterImage, | ||||||
|   updateCharacterImage, |   updateCharacterImage, | ||||||
|   deleteCharacterImage, |   deleteCharacterImage, | ||||||
|   updateCharacterImageOrder |   updateCharacterImageOrder, | ||||||
|  |   // Character Curation | ||||||
|  |   getCharacterCurationList, | ||||||
|  |   createCharacterCuration, | ||||||
|  |   updateCharacterCuration, | ||||||
|  |   deleteCharacterCuration, | ||||||
|  |   updateCharacterCurationOrder, | ||||||
|  |   addCharacterToCuration, | ||||||
|  |   removeCharacterFromCuration, | ||||||
|  |   updateCurationCharactersOrder, | ||||||
|  |   getCharactersInCuration | ||||||
| } | } | ||||||
|   | |||||||
| @@ -111,6 +111,11 @@ export default { | |||||||
|                 title: '캐릭터 리스트', |                 title: '캐릭터 리스트', | ||||||
|                 route: '/character', |                 route: '/character', | ||||||
|                 items: null |                 items: null | ||||||
|  |               }, | ||||||
|  |               { | ||||||
|  |                 title: '큐레이션', | ||||||
|  |                 route: '/character/curation', | ||||||
|  |                 items: null | ||||||
|               } |               } | ||||||
|             ] |             ] | ||||||
|           }) |           }) | ||||||
|   | |||||||
| @@ -280,6 +280,16 @@ const routes = [ | |||||||
|                 name: 'CharacterImageForm', |                 name: 'CharacterImageForm', | ||||||
|                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue') |                 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') | ||||||
|  |             }, | ||||||
|         ] |         ] | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|   | |||||||
							
								
								
									
										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> | ||||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung