캐릭터 챗봇 #74
| @@ -1,16 +1,112 @@ | ||||
| import Vue from 'vue'; | ||||
|  | ||||
| // 캐릭터 리스트 | ||||
| async function getCharacterList() { | ||||
|   return Vue.axios.get('/api/admin/characters') | ||||
| async function getCharacterList(page = 1, size = 10) { | ||||
|   return Vue.axios.get('/api/admin/characters', { | ||||
|     params: { page, size } | ||||
|   }) | ||||
| } | ||||
|  | ||||
| // 캐릭터 검색 | ||||
| async function searchCharacters(keyword, page = 1, size = 10) { | ||||
|   return Vue.axios.get('/api/admin/characters/search', { | ||||
|     params: { keyword, page, size } | ||||
|   }) | ||||
| } | ||||
|  | ||||
| // 캐릭터 상세 조회 | ||||
| async function getCharacter(id) { | ||||
|   return Vue.axios.get(`/api/admin/characters/${id}`) | ||||
| } | ||||
|  | ||||
| // 캐릭터 등록 | ||||
| async function createCharacter(characterData) { | ||||
|   const formData = new FormData() | ||||
|  | ||||
|   // 기본 필드 추가 | ||||
|   formData.append('name', characterData.name) | ||||
|   formData.append('description', characterData.description) | ||||
|   formData.append('isActive', characterData.isActive) | ||||
|  | ||||
|   // 추가 필드가 있는 경우 추가 | ||||
|   if (characterData.personality) formData.append('personality', characterData.personality) | ||||
|   if (characterData.gender) formData.append('gender', characterData.gender) | ||||
|   if (characterData.birthDate) formData.append('birthDate', characterData.birthDate) | ||||
|   if (characterData.mbti) formData.append('mbti', characterData.mbti) | ||||
|   if (characterData.ageRestricted !== undefined) formData.append('ageRestricted', characterData.ageRestricted) | ||||
|   if (characterData.worldView) formData.append('worldView', characterData.worldView) | ||||
|   if (characterData.relationships) formData.append('relationships', characterData.relationships) | ||||
|   if (characterData.speechPattern) formData.append('speechPattern', characterData.speechPattern) | ||||
|   if (characterData.systemPrompt) formData.append('systemPrompt', characterData.systemPrompt) | ||||
|  | ||||
|   // 태그와 메모리는 배열이므로 JSON 문자열로 변환 | ||||
|   if (characterData.tags && characterData.tags.length > 0) { | ||||
|     formData.append('tags', JSON.stringify(characterData.tags)) | ||||
|   } | ||||
|  | ||||
|   if (characterData.memories && characterData.memories.length > 0) { | ||||
|     formData.append('memories', JSON.stringify(characterData.memories)) | ||||
|   } | ||||
|  | ||||
|   // 이미지가 있는 경우 추가 | ||||
|   if (characterData.image) formData.append('image', characterData.image) | ||||
|  | ||||
|   return Vue.axios.post('/api/admin/characters', formData, { | ||||
|     headers: { | ||||
|       'Content-Type': 'multipart/form-data' | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|  | ||||
| // 캐릭터 수정 | ||||
| async function updateCharacter(characterData) { | ||||
|   const formData = new FormData() | ||||
|  | ||||
|   // 기본 필드 추가 | ||||
|   formData.append('name', characterData.name) | ||||
|   formData.append('description', characterData.description) | ||||
|   formData.append('isActive', characterData.isActive) | ||||
|  | ||||
|   // 추가 필드가 있는 경우 추가 | ||||
|   if (characterData.personality) formData.append('personality', characterData.personality) | ||||
|   if (characterData.gender) formData.append('gender', characterData.gender) | ||||
|   if (characterData.birthDate) formData.append('birthDate', characterData.birthDate) | ||||
|   if (characterData.mbti) formData.append('mbti', characterData.mbti) | ||||
|   if (characterData.ageRestricted !== undefined) formData.append('ageRestricted', characterData.ageRestricted) | ||||
|   if (characterData.worldView) formData.append('worldView', characterData.worldView) | ||||
|   if (characterData.relationships) formData.append('relationships', characterData.relationships) | ||||
|   if (characterData.speechPattern) formData.append('speechPattern', characterData.speechPattern) | ||||
|   if (characterData.systemPrompt) formData.append('systemPrompt', characterData.systemPrompt) | ||||
|  | ||||
|   // 태그와 메모리는 배열이므로 JSON 문자열로 변환 | ||||
|   if (characterData.tags && characterData.tags.length > 0) { | ||||
|     formData.append('tags', JSON.stringify(characterData.tags)) | ||||
|   } | ||||
|  | ||||
|   if (characterData.memories && characterData.memories.length > 0) { | ||||
|     formData.append('memories', JSON.stringify(characterData.memories)) | ||||
|   } | ||||
|  | ||||
|   // 이미지가 있는 경우 추가 | ||||
|   if (characterData.image) formData.append('image', characterData.image) | ||||
|  | ||||
|   return Vue.axios.put(`/api/admin/characters/${characterData.id}`, formData, { | ||||
|     headers: { | ||||
|       'Content-Type': 'multipart/form-data' | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|  | ||||
| // 캐릭터 삭제 | ||||
| async function deleteCharacter(id) { | ||||
|   return Vue.axios.delete(`/api/admin/characters/${id}`) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   getCharacterList | ||||
|   getCharacterList, | ||||
|   searchCharacters, | ||||
|   getCharacter, | ||||
|   createCharacter, | ||||
|   updateCharacter, | ||||
|   deleteCharacter | ||||
| } | ||||
|   | ||||
| @@ -260,6 +260,11 @@ const routes = [ | ||||
|                 name: 'CharacterList', | ||||
|                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue') | ||||
|             }, | ||||
|             { | ||||
|                 path: '/character/form', | ||||
|                 name: 'CharacterForm', | ||||
|                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue') | ||||
|             }, | ||||
|             // TODO: CharacterBanner 컴포넌트가 구현되면 아래 라우트 주석 해제 | ||||
|             // { | ||||
|             //     path: '/character/banner', | ||||
|   | ||||
							
								
								
									
										620
									
								
								src/views/Chat/CharacterForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										620
									
								
								src/views/Chat/CharacterForm.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,620 @@ | ||||
| <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 | ||||
|                 cols="12" | ||||
|                 md="6" | ||||
|               > | ||||
|                 <v-file-input | ||||
|                   v-model="character.image" | ||||
|                   label="캐릭터 이미지" | ||||
|                   accept="image/*" | ||||
|                   prepend-icon="mdi-camera" | ||||
|                   show-size | ||||
|                   truncate-length="15" | ||||
|                   outlined | ||||
|                   dense | ||||
|                   :rules="imageRules" | ||||
|                 /> | ||||
|               </v-col> | ||||
|  | ||||
|               <v-col | ||||
|                 v-if="previewImage || character.imageUrl" | ||||
|                 cols="12" | ||||
|                 md="6" | ||||
|               > | ||||
|                 <div class="text-center"> | ||||
|                   <v-avatar size="150"> | ||||
|                     <v-img | ||||
|                       :src="previewImage || character.imageUrl" | ||||
|                       alt="캐릭터 이미지" | ||||
|                       contain | ||||
|                     /> | ||||
|                   </v-avatar> | ||||
|                 </div> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 캐릭터명 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-text-field | ||||
|                   v-model="character.name" | ||||
|                   label="캐릭터명" | ||||
|                   :rules="nameRules" | ||||
|                   required | ||||
|                   outlined | ||||
|                   dense | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 캐릭터 설명 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.description" | ||||
|                   label="캐릭터 설명" | ||||
|                   :rules="descriptionRules" | ||||
|                   required | ||||
|                   outlined | ||||
|                   auto-grow | ||||
|                   rows="4" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 성별 --> | ||||
|             <v-row> | ||||
|               <v-col | ||||
|                 cols="12" | ||||
|                 md="6" | ||||
|               > | ||||
|                 <v-select | ||||
|                   v-model="character.gender" | ||||
|                   :items="genderOptions" | ||||
|                   label="성별" | ||||
|                   outlined | ||||
|                   dense | ||||
|                 /> | ||||
|               </v-col> | ||||
|  | ||||
|               <!-- 생년월일 --> | ||||
|               <v-col | ||||
|                 cols="12" | ||||
|                 md="6" | ||||
|               > | ||||
|                 <v-menu | ||||
|                   v-model="birthDateMenu" | ||||
|                   :close-on-content-click="false" | ||||
|                   transition="scale-transition" | ||||
|                   offset-y | ||||
|                   min-width="auto" | ||||
|                 > | ||||
|                   <template v-slot:activator="{ on, attrs }"> | ||||
|                     <v-text-field | ||||
|                       v-model="formattedBirthDate" | ||||
|                       label="생년월일" | ||||
|                       readonly | ||||
|                       outlined | ||||
|                       dense | ||||
|                       v-bind="attrs" | ||||
|                       v-on="on" | ||||
|                     /> | ||||
|                   </template> | ||||
|                   <v-date-picker | ||||
|                     v-model="character.birthDate" | ||||
|                     locale="ko" | ||||
|                     @input="birthDateMenu = false" | ||||
|                   /> | ||||
|                 </v-menu> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- MBTI --> | ||||
|             <v-row> | ||||
|               <v-col | ||||
|                 cols="12" | ||||
|                 md="6" | ||||
|               > | ||||
|                 <v-select | ||||
|                   v-model="character.mbti" | ||||
|                   :items="mbtiOptions" | ||||
|                   label="MBTI" | ||||
|                   outlined | ||||
|                   dense | ||||
|                 /> | ||||
|               </v-col> | ||||
|  | ||||
|               <!-- 연령제한 - 스위치 형태 --> | ||||
|               <v-col | ||||
|                 cols="12" | ||||
|                 md="6" | ||||
|               > | ||||
|                 <v-switch | ||||
|                   v-model="character.ageRestricted" | ||||
|                   label="연령제한" | ||||
|                   color="primary" | ||||
|                   hide-details | ||||
|                   class="mt-4" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 태그 - 입력 후 띄어쓰기 할 때마다 Chip 형태로 변경 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-combobox | ||||
|                   v-model="tags" | ||||
|                   label="태그" | ||||
|                   multiple | ||||
|                   chips | ||||
|                   small-chips | ||||
|                   deletable-chips | ||||
|                   outlined | ||||
|                   dense | ||||
|                   @keydown.space.prevent="addTag" | ||||
|                 > | ||||
|                   <template v-slot:selection="{ attrs, item, select, selected }"> | ||||
|                     <v-chip | ||||
|                       v-bind="attrs" | ||||
|                       :input-value="selected" | ||||
|                       close | ||||
|                       @click="select" | ||||
|                       @click:close="removeTag(item)" | ||||
|                     > | ||||
|                       {{ item }} | ||||
|                     </v-chip> | ||||
|                   </template> | ||||
|                 </v-combobox> | ||||
|                 <div class="caption grey--text text--darken-1"> | ||||
|                   태그를 입력하고 스페이스바를 누르면 추가됩니다. | ||||
|                 </div> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 긴 텍스트 섹션 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-divider class="my-4" /> | ||||
|                 <h3 class="mb-4"> | ||||
|                   상세 정보 | ||||
|                 </h3> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 세계관 (배경이야기) --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.worldView" | ||||
|                   label="세계관 (배경이야기)" | ||||
|                   outlined | ||||
|                   auto-grow | ||||
|                   rows="4" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 인물 관계 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.relationships" | ||||
|                   label="인물 관계" | ||||
|                   outlined | ||||
|                   auto-grow | ||||
|                   rows="4" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 성격 특성 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.personality" | ||||
|                   label="성격 특성" | ||||
|                   outlined | ||||
|                   auto-grow | ||||
|                   rows="4" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 말투/특징적 표현 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.speechPattern" | ||||
|                   label="말투/특징적 표현" | ||||
|                   outlined | ||||
|                   auto-grow | ||||
|                   rows="4" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 시스템 프롬프트 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.systemPrompt" | ||||
|                   label="시스템 프롬프트" | ||||
|                   outlined | ||||
|                   auto-grow | ||||
|                   rows="4" | ||||
|                 /> | ||||
|                 <div class="caption grey--text text--darken-1 mt-1"> | ||||
|                   캐릭터의 행동 방식과 제약사항을 정의하는 시스템 프롬프트입니다. | ||||
|                 </div> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 기억/메모리 --> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-divider class="my-4" /> | ||||
|                 <h3 class="mb-2"> | ||||
|                   기억/메모리 | ||||
|                 </h3> | ||||
|  | ||||
|                 <v-row> | ||||
|                   <v-col cols="12"> | ||||
|                     <v-row> | ||||
|                       <v-col cols="11"> | ||||
|                         <v-text-field | ||||
|                           v-model="newMemory" | ||||
|                           label="새 기억 추가" | ||||
|                           outlined | ||||
|                           dense | ||||
|                           @keyup.enter="addMemory" | ||||
|                         /> | ||||
|                       </v-col> | ||||
|                       <v-col cols="1"> | ||||
|                         <v-btn | ||||
|                           color="primary" | ||||
|                           class="mt-1" | ||||
|                           block | ||||
|                           :disabled="!newMemory.trim()" | ||||
|                           @click="addMemory" | ||||
|                         > | ||||
|                           추가 | ||||
|                         </v-btn> | ||||
|                       </v-col> | ||||
|                     </v-row> | ||||
|                   </v-col> | ||||
|                 </v-row> | ||||
|  | ||||
|                 <v-card | ||||
|                   outlined | ||||
|                   class="memory-container" | ||||
|                 > | ||||
|                   <v-list | ||||
|                     v-if="memories.length > 0" | ||||
|                     class="memory-list" | ||||
|                   > | ||||
|                     <v-list-item | ||||
|                       v-for="(memory, index) in memories" | ||||
|                       :key="index" | ||||
|                       class="memory-item" | ||||
|                     > | ||||
|                       <v-list-item-content> | ||||
|                         <v-list-item-title class="memory-text"> | ||||
|                           {{ memory }} | ||||
|                         </v-list-item-title> | ||||
|                       </v-list-item-content> | ||||
|                       <v-list-item-action> | ||||
|                         <v-btn | ||||
|                           small | ||||
|                           color="error" | ||||
|                           class="delete-btn" | ||||
|                           @click="removeMemory(index)" | ||||
|                         > | ||||
|                           삭제 | ||||
|                         </v-btn> | ||||
|                       </v-list-item-action> | ||||
|                     </v-list-item> | ||||
|                   </v-list> | ||||
|                   <v-card-text | ||||
|                     v-else | ||||
|                     class="text-center grey--text" | ||||
|                   > | ||||
|                     기억이 없습니다. 위 입력창에서 기억을 추가해주세요. | ||||
|                   </v-card-text> | ||||
|                 </v-card> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|  | ||||
|             <!-- 활성화 상태 --> | ||||
|             <v-row v-show="isEdit"> | ||||
|               <v-col cols="12"> | ||||
|                 <v-divider class="my-4" /> | ||||
|                 <v-switch | ||||
|                   v-model="character.isActive" | ||||
|                   label="캐릭터 활성화" | ||||
|                   color="primary" | ||||
|                   hide-details | ||||
|                   class="mt-2" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|           </v-card-text> | ||||
|  | ||||
|           <v-card-actions> | ||||
|             <v-spacer /> | ||||
|             <v-btn | ||||
|               color="error" | ||||
|               text | ||||
|               :disabled="isLoading" | ||||
|               @click="goBack" | ||||
|             > | ||||
|               취소 | ||||
|             </v-btn> | ||||
|             <v-btn | ||||
|               color="primary" | ||||
|               :loading="isLoading" | ||||
|               :disabled="!isFormValid || isLoading" | ||||
|               @click="saveCharacter" | ||||
|             > | ||||
|               저장 | ||||
|             </v-btn> | ||||
|           </v-card-actions> | ||||
|         </v-form> | ||||
|       </v-card> | ||||
|     </v-container> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import {getCharacter, createCharacter, updateCharacter} from '@/api/character'; | ||||
|  | ||||
| export default { | ||||
|   name: "CharacterForm", | ||||
|  | ||||
|   data() { | ||||
|     return { | ||||
|       isLoading: false, | ||||
|       isFormValid: false, | ||||
|       isEdit: false, | ||||
|       previewImage: null, | ||||
|       birthDateMenu: false, | ||||
|       newMemory: '', | ||||
|       tags: [], | ||||
|       memories: [], | ||||
|       character: { | ||||
|         id: null, | ||||
|         name: '', | ||||
|         description: '', | ||||
|         image: null, | ||||
|         imageUrl: '', | ||||
|         isActive: true, | ||||
|         createdAt: '', | ||||
|         gender: '', | ||||
|         birthDate: null, | ||||
|         mbti: '', | ||||
|         ageRestricted: false, | ||||
|         worldView: '', | ||||
|         relationships: '', | ||||
|         personality: '', | ||||
|         speechPattern: '', | ||||
|         systemPrompt: '', | ||||
|         tags: [], | ||||
|         memories: [] | ||||
|       }, | ||||
|       nameRules: [ | ||||
|         v => !!v || '이름을 입력하세요', | ||||
|         v => (v && v.trim().length > 0) || '이름을 입력하세요' | ||||
|       ], | ||||
|       descriptionRules: [ | ||||
|         v => !!v || '설명을 입력하세요', | ||||
|         v => (v && v.trim().length > 0) || '설명을 입력하세요' | ||||
|       ], | ||||
|       imageRules: [ | ||||
|         v => !this.isEdit || !!v || !!this.character.imageUrl || '이미지를 선택하세요' | ||||
|       ], | ||||
|       genderOptions: ['남성', '여성', '기타'], | ||||
|       mbtiOptions: [ | ||||
|         'INTJ', 'INTP', 'ENTJ', 'ENTP', | ||||
|         'INFJ', 'INFP', 'ENFJ', 'ENFP', | ||||
|         'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', | ||||
|         'ISTP', 'ISFP', 'ESTP', 'ESFP' | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   computed: { | ||||
|     formattedBirthDate() { | ||||
|       if (!this.character.birthDate) return ''; | ||||
|       return this.character.birthDate; | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   watch: { | ||||
|     'character.image': { | ||||
|       handler(newImage) { | ||||
|         if (newImage) { | ||||
|           this.createImagePreview(newImage); | ||||
|         } else { | ||||
|           this.previewImage = null; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   created() { | ||||
|     // 수정 모드인지 확인 | ||||
|     if (this.$route.query.id) { | ||||
|       this.isEdit = true; | ||||
|       this.loadCharacter(this.$route.query.id); | ||||
|     } | ||||
|   }, | ||||
|  | ||||
|   methods: { | ||||
|     notifyError(message) { | ||||
|       this.$dialog.notify.error(message); | ||||
|     }, | ||||
|  | ||||
|     notifySuccess(message) { | ||||
|       this.$dialog.notify.success(message); | ||||
|     }, | ||||
|  | ||||
|     goBack() { | ||||
|       this.$router.push('/character'); | ||||
|     }, | ||||
|  | ||||
|     createImagePreview(file) { | ||||
|       if (!file) return; | ||||
|  | ||||
|       const reader = new FileReader(); | ||||
|       reader.onload = (e) => { | ||||
|         this.previewImage = e.target.result; | ||||
|       }; | ||||
|       reader.readAsDataURL(file); | ||||
|     }, | ||||
|  | ||||
|     addTag() { | ||||
|       const lastTag = this.tags[this.tags.length - 1]; | ||||
|       if (lastTag && typeof lastTag === 'string' && lastTag.trim()) { | ||||
|         this.tags.splice(this.tags.length - 1, 1, lastTag.trim()); | ||||
|         this.$nextTick(() => { | ||||
|           this.tags.push(''); | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     removeTag(item) { | ||||
|       this.tags.splice(this.tags.indexOf(item), 1); | ||||
|     }, | ||||
|  | ||||
|     addMemory() { | ||||
|       if (this.newMemory.trim()) { | ||||
|         this.memories.push(this.newMemory.trim()); | ||||
|         this.newMemory = ''; | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     removeMemory(index) { | ||||
|       this.memories.splice(index, 1); | ||||
|     }, | ||||
|  | ||||
|     async loadCharacter(id) { | ||||
|       this.isLoading = true; | ||||
|       try { | ||||
|         const response = await getCharacter(id); | ||||
|  | ||||
|         // API 응답에서 캐릭터 정보 설정 | ||||
|         if (response && response.data) { | ||||
|           const data = response.data; | ||||
|  | ||||
|           // 기본 데이터 설정 | ||||
|           this.character = { | ||||
|             ...this.character, // 기본 구조 유지 | ||||
|             ...data,  // API 응답 데이터로 덮어쓰기 | ||||
|             image: null        // 파일 입력은 초기화 | ||||
|           }; | ||||
|  | ||||
|           // 태그와 메모리 설정 | ||||
|           this.tags = data.tags || []; | ||||
|           this.memories = data.memories || []; | ||||
|         } else { | ||||
|           this.notifyError('캐릭터 정보를 불러올 수 없습니다.'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error('캐릭터 정보 로드 오류:', e); | ||||
|         this.notifyError('캐릭터 정보를 불러오는데 실패했습니다.'); | ||||
|       } finally { | ||||
|         this.isLoading = false; | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     async saveCharacter() { | ||||
|       if (!this.isFormValid) { | ||||
|         this.notifyError("필수 항목을 모두 입력하세요"); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (this.isLoading) return; | ||||
|  | ||||
|       this.isLoading = true; | ||||
|  | ||||
|       try { | ||||
|         // 태그와 메모리 데이터 설정 | ||||
|         this.character.tags = this.tags.filter(tag => tag && typeof tag === 'string' && tag.trim()); | ||||
|         this.character.memories = [...this.memories]; | ||||
|  | ||||
|         let response; | ||||
|  | ||||
|         if (this.isEdit) { | ||||
|           response = await updateCharacter(this.character); | ||||
|         } else { | ||||
|           response = await createCharacter(this.character); | ||||
|         } | ||||
|  | ||||
|         if (response && response.data) { | ||||
|           this.notifySuccess(this.isEdit ? '캐릭터가 수정되었습니다.' : '캐릭터가 등록되었습니다.'); | ||||
|           this.goBack(); | ||||
|         } else { | ||||
|           this.notifyError('응답 데이터가 없습니다.'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error('캐릭터 저장 오류:', e); | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
|       } finally { | ||||
|         this.isLoading = false; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .v-card { | ||||
|   border-radius: 8px; | ||||
| } | ||||
|  | ||||
| .memory-container { | ||||
|   max-height: 300px; | ||||
|   overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .memory-list { | ||||
|   max-height: 280px; | ||||
|   overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .memory-item { | ||||
|   background-color: #f5f5f5; | ||||
|   margin-bottom: 4px; | ||||
|   border-radius: 4px; | ||||
| } | ||||
|  | ||||
| .memory-text { | ||||
|   white-space: pre-wrap; | ||||
|   word-break: break-word; | ||||
| } | ||||
|  | ||||
| .delete-btn { | ||||
|   font-size: 12px; | ||||
| } | ||||
| </style> | ||||
| @@ -139,77 +139,6 @@ | ||||
|       </v-row> | ||||
|     </v-container> | ||||
|  | ||||
|     <!-- 캐릭터 추가/수정 다이얼로그 --> | ||||
|     <v-dialog | ||||
|       v-model="show_dialog" | ||||
|       max-width="600px" | ||||
|       persistent | ||||
|     > | ||||
|       <v-card> | ||||
|         <v-card-title> | ||||
|           {{ is_edit ? '캐릭터 수정' : '캐릭터 추가' }} | ||||
|         </v-card-title> | ||||
|         <v-card-text> | ||||
|           <v-container> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-text-field | ||||
|                   v-model="character.name" | ||||
|                   label="이름" | ||||
|                   required | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.description" | ||||
|                   label="설명" | ||||
|                   required | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-file-input | ||||
|                   v-model="character.image" | ||||
|                   label="캐릭터 이미지" | ||||
|                   accept="image/*" | ||||
|                   prepend-icon="mdi-camera" | ||||
|                   show-size | ||||
|                   truncate-length="15" | ||||
|                 /> | ||||
|               </v-col> | ||||
|             </v-row> | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-switch | ||||
|                   v-model="character.isActive" | ||||
|                   label="활성화" | ||||
|                 /> | ||||
|               </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 | ||||
|             @click="saveCharacter" | ||||
|           > | ||||
|             저장 | ||||
|           </v-btn> | ||||
|         </v-card-actions> | ||||
|       </v-card> | ||||
|     </v-dialog> | ||||
|  | ||||
|     <!-- 삭제 확인 다이얼로그 --> | ||||
|     <v-dialog | ||||
| @@ -246,6 +175,7 @@ | ||||
|  | ||||
| <script> | ||||
| import VueShowMoreText from 'vue-show-more-text' | ||||
| import { getCharacterList, searchCharacters, deleteCharacter as apiDeleteCharacter } from '@/api/character' | ||||
|  | ||||
| export default { | ||||
|   name: "CharacterList", | ||||
| @@ -255,21 +185,10 @@ export default { | ||||
|   data() { | ||||
|     return { | ||||
|       is_loading: false, | ||||
|       show_dialog: false, | ||||
|       show_delete_confirm_dialog: false, | ||||
|       is_edit: false, | ||||
|       page: 1, | ||||
|       total_page: 0, | ||||
|       search_word: '', | ||||
|       character: { | ||||
|         id: null, | ||||
|         name: '', | ||||
|         description: '', | ||||
|         image: null, | ||||
|         imageUrl: '', | ||||
|         isActive: true, | ||||
|         createdAt: '' | ||||
|       }, | ||||
|       characters: [], | ||||
|       selected_character: {} | ||||
|     } | ||||
| @@ -289,47 +208,18 @@ export default { | ||||
|     }, | ||||
|  | ||||
|     showAddDialog() { | ||||
|       this.is_edit = false | ||||
|       this.character = { | ||||
|         id: null, | ||||
|         name: '', | ||||
|         description: '', | ||||
|         image: null, | ||||
|         imageUrl: '', | ||||
|         isActive: true, | ||||
|         createdAt: '' | ||||
|       } | ||||
|       this.show_dialog = true | ||||
|       // 페이지로 이동 | ||||
|       this.$router.push('/character/form'); | ||||
|     }, | ||||
|  | ||||
|     showEditDialog(item) { | ||||
|       this.is_edit = true | ||||
|       this.selected_character = item | ||||
|       this.character = { | ||||
|         id: item.id, | ||||
|         name: item.name, | ||||
|         description: item.description, | ||||
|         image: null, | ||||
|         imageUrl: item.imageUrl, | ||||
|         isActive: item.isActive, | ||||
|         createdAt: item.createdAt | ||||
|       } | ||||
|       this.show_dialog = true | ||||
|       // 페이지로 이동하면서 id 전달 | ||||
|       this.$router.push({ | ||||
|         path: '/character/form', | ||||
|         query: { id: item.id } | ||||
|       }); | ||||
|     }, | ||||
|  | ||||
|     closeDialog() { | ||||
|       this.show_dialog = false | ||||
|       this.character = { | ||||
|         id: null, | ||||
|         name: '', | ||||
|         description: '', | ||||
|         image: null, | ||||
|         imageUrl: '', | ||||
|         isActive: true, | ||||
|         createdAt: '' | ||||
|       } | ||||
|       this.selected_character = {} | ||||
|     }, | ||||
|  | ||||
|     deleteConfirm(item) { | ||||
|       this.selected_character = item | ||||
| @@ -341,68 +231,21 @@ export default { | ||||
|       this.selected_character = {} | ||||
|     }, | ||||
|  | ||||
|     async saveCharacter() { | ||||
|       if ( | ||||
|         this.character.name === null || | ||||
|         this.character.name === undefined || | ||||
|         this.character.name.trim().length <= 0 | ||||
|       ) { | ||||
|         this.notifyError("이름을 입력하세요") | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       if ( | ||||
|         this.character.description === null || | ||||
|         this.character.description === undefined || | ||||
|         this.character.description.trim().length <= 0 | ||||
|       ) { | ||||
|         this.notifyError("설명을 입력하세요") | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       if (this.is_loading) return; | ||||
|  | ||||
|       this.is_loading = true | ||||
|  | ||||
|       try { | ||||
|         // 여기에 API 호출 코드를 추가합니다. | ||||
|         // 예시: | ||||
|         // const res = this.is_edit | ||||
|         //   ? await api.updateCharacter(this.character) | ||||
|         //   : await api.createCharacter(this.character); | ||||
|  | ||||
|         // API 호출이 없으므로 임시로 성공 처리 | ||||
|         setTimeout(() => { | ||||
|           this.closeDialog() | ||||
|           this.notifySuccess(this.is_edit ? '수정되었습니다.' : '추가되었습니다.') | ||||
|           this.getCharacters() | ||||
|           this.is_loading = false | ||||
|         }, 1000) | ||||
|       } catch (e) { | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') | ||||
|         this.is_loading = false | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     async deleteCharacter() { | ||||
|       if (this.is_loading) return; | ||||
|       this.is_loading = true | ||||
|  | ||||
|       try { | ||||
|         // 여기에 API 호출 코드를 추가합니다. | ||||
|         // 예시: | ||||
|         // const res = await api.deleteCharacter(this.selected_character.id); | ||||
|  | ||||
|         // API 호출이 없으므로 임시로 성공 처리 | ||||
|         setTimeout(() => { | ||||
|           this.closeDeleteDialog() | ||||
|           this.notifySuccess('삭제되었습니다.') | ||||
|           this.getCharacters() | ||||
|           this.is_loading = false | ||||
|         }, 1000) | ||||
|         await apiDeleteCharacter(this.selected_character.id); | ||||
|         this.closeDeleteDialog(); | ||||
|         this.notifySuccess('삭제되었습니다.'); | ||||
|         await this.getCharacters(); | ||||
|       } catch (e) { | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') | ||||
|         this.is_loading = false | ||||
|         console.error('캐릭터 삭제 오류:', e); | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
|       } finally { | ||||
|         this.is_loading = false; | ||||
|       } | ||||
|     }, | ||||
|  | ||||
| @@ -418,38 +261,22 @@ export default { | ||||
|     async getCharacters() { | ||||
|       this.is_loading = true | ||||
|       try { | ||||
|         // 여기에 API 호출 코드를 추가합니다. | ||||
|         // 예시: | ||||
|         // const res = await api.getCharacters(this.page); | ||||
|         const response = await getCharacterList(this.page); | ||||
|  | ||||
|         // API 호출이 없으므로 임시 데이터 생성 | ||||
|         setTimeout(() => { | ||||
|           // 임시 데이터 | ||||
|           const mockData = { | ||||
|             totalCount: 15, | ||||
|             items: Array.from({ length: 10 }, (_, i) => ({ | ||||
|               id: i + 1 + (this.page - 1) * 10, | ||||
|               name: `캐릭터 ${i + 1 + (this.page - 1) * 10}`, | ||||
|               description: `이것은 캐릭터 ${i + 1 + (this.page - 1) * 10}에 대한 설명입니다. 이 캐릭터는 다양한 특성을 가지고 있습니다.`, | ||||
|               imageUrl: 'https://via.placeholder.com/150', | ||||
|               isActive: Math.random() > 0.3, | ||||
|               createdAt: new Date().toISOString().split('T')[0] | ||||
|             })) | ||||
|           } | ||||
|         if (response && response.data) { | ||||
|           const data = response.data; | ||||
|           this.characters = data.items || []; | ||||
|  | ||||
|           const total_page = Math.ceil(mockData.totalCount / 10) | ||||
|           this.characters = mockData.items | ||||
|  | ||||
|           if (total_page <= 0) | ||||
|             this.total_page = 1 | ||||
|           else | ||||
|             this.total_page = total_page | ||||
|  | ||||
|           this.is_loading = false | ||||
|         }, 500) | ||||
|           const total_page = Math.ceil((data.totalCount || 0) / 10); | ||||
|           this.total_page = total_page <= 0 ? 1 : total_page; | ||||
|         } else { | ||||
|           throw new Error('응답 데이터가 없습니다.'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') | ||||
|         this.is_loading = false | ||||
|         console.error('캐릭터 목록 조회 오류:', e); | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
|       } finally { | ||||
|         this.is_loading = false; | ||||
|       } | ||||
|     }, | ||||
|  | ||||
| @@ -466,40 +293,22 @@ export default { | ||||
|       } else { | ||||
|         this.is_loading = true | ||||
|         try { | ||||
|           // 여기에 API 호출 코드를 추가합니다. | ||||
|           // 예시: | ||||
|           // const res = await api.searchCharacters(this.search_word, this.page); | ||||
|           const response = await searchCharacters(this.search_word, this.page); | ||||
|  | ||||
|           // API 호출이 없으므로 임시 데이터 생성 | ||||
|           setTimeout(() => { | ||||
|             // 검색 결과 임시 데이터 | ||||
|             const filteredItems = Array.from({ length: 3 }, (_, i) => ({ | ||||
|               id: i + 1, | ||||
|               name: `${this.search_word} 캐릭터 ${i + 1}`, | ||||
|               description: `이것은 ${this.search_word} 캐릭터 ${i + 1}에 대한 설명입니다.`, | ||||
|               imageUrl: 'https://via.placeholder.com/150', | ||||
|               isActive: true, | ||||
|               createdAt: new Date().toISOString().split('T')[0] | ||||
|             })) | ||||
|           if (response && response.data) { | ||||
|             const data = response.data; | ||||
|             this.characters = data.items || []; | ||||
|  | ||||
|             const mockData = { | ||||
|               totalCount: 3, | ||||
|               items: filteredItems | ||||
|             } | ||||
|  | ||||
|             const total_page = Math.ceil(mockData.totalCount / 10) | ||||
|             this.characters = mockData.items | ||||
|  | ||||
|             if (total_page <= 0) | ||||
|               this.total_page = 1 | ||||
|             else | ||||
|               this.total_page = total_page | ||||
|  | ||||
|             this.is_loading = false | ||||
|           }, 500) | ||||
|             const total_page = Math.ceil((data.totalCount || 0) / 10); | ||||
|             this.total_page = total_page <= 0 ? 1 : total_page; | ||||
|           } else { | ||||
|             throw new Error('응답 데이터가 없습니다.'); | ||||
|           } | ||||
|         } catch (e) { | ||||
|           this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') | ||||
|           this.is_loading = false | ||||
|           console.error('캐릭터 검색 오류:', e); | ||||
|           this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
|         } finally { | ||||
|           this.is_loading = false; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user