캐릭터 챗봇 #74
| @@ -1,16 +1,112 @@ | |||||||
| import Vue from 'vue'; | import Vue from 'vue'; | ||||||
|  |  | ||||||
| // 캐릭터 리스트 | // 캐릭터 리스트 | ||||||
| async function getCharacterList() { | async function getCharacterList(page = 1, size = 10) { | ||||||
|   return Vue.axios.get('/api/admin/characters') |   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 { | export { | ||||||
|   getCharacterList |   getCharacterList, | ||||||
|  |   searchCharacters, | ||||||
|  |   getCharacter, | ||||||
|  |   createCharacter, | ||||||
|  |   updateCharacter, | ||||||
|  |   deleteCharacter | ||||||
| } | } | ||||||
|   | |||||||
| @@ -260,6 +260,11 @@ const routes = [ | |||||||
|                 name: 'CharacterList', |                 name: 'CharacterList', | ||||||
|                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue') |                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue') | ||||||
|             }, |             }, | ||||||
|  |             { | ||||||
|  |                 path: '/character/form', | ||||||
|  |                 name: 'CharacterForm', | ||||||
|  |                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue') | ||||||
|  |             }, | ||||||
|             // TODO: CharacterBanner 컴포넌트가 구현되면 아래 라우트 주석 해제 |             // TODO: CharacterBanner 컴포넌트가 구현되면 아래 라우트 주석 해제 | ||||||
|             // { |             // { | ||||||
|             //     path: '/character/banner', |             //     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-row> | ||||||
|     </v-container> |     </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 |     <v-dialog | ||||||
| @@ -246,6 +175,7 @@ | |||||||
|  |  | ||||||
| <script> | <script> | ||||||
| import VueShowMoreText from 'vue-show-more-text' | import VueShowMoreText from 'vue-show-more-text' | ||||||
|  | import { getCharacterList, searchCharacters, deleteCharacter as apiDeleteCharacter } from '@/api/character' | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   name: "CharacterList", |   name: "CharacterList", | ||||||
| @@ -255,21 +185,10 @@ export default { | |||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       is_loading: false, |       is_loading: false, | ||||||
|       show_dialog: false, |  | ||||||
|       show_delete_confirm_dialog: false, |       show_delete_confirm_dialog: false, | ||||||
|       is_edit: false, |  | ||||||
|       page: 1, |       page: 1, | ||||||
|       total_page: 0, |       total_page: 0, | ||||||
|       search_word: '', |       search_word: '', | ||||||
|       character: { |  | ||||||
|         id: null, |  | ||||||
|         name: '', |  | ||||||
|         description: '', |  | ||||||
|         image: null, |  | ||||||
|         imageUrl: '', |  | ||||||
|         isActive: true, |  | ||||||
|         createdAt: '' |  | ||||||
|       }, |  | ||||||
|       characters: [], |       characters: [], | ||||||
|       selected_character: {} |       selected_character: {} | ||||||
|     } |     } | ||||||
| @@ -289,47 +208,18 @@ export default { | |||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     showAddDialog() { |     showAddDialog() { | ||||||
|       this.is_edit = false |       // 페이지로 이동 | ||||||
|       this.character = { |       this.$router.push('/character/form'); | ||||||
|         id: null, |  | ||||||
|         name: '', |  | ||||||
|         description: '', |  | ||||||
|         image: null, |  | ||||||
|         imageUrl: '', |  | ||||||
|         isActive: true, |  | ||||||
|         createdAt: '' |  | ||||||
|       } |  | ||||||
|       this.show_dialog = true |  | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     showEditDialog(item) { |     showEditDialog(item) { | ||||||
|       this.is_edit = true |       // 페이지로 이동하면서 id 전달 | ||||||
|       this.selected_character = item |       this.$router.push({ | ||||||
|       this.character = { |         path: '/character/form', | ||||||
|         id: item.id, |         query: { id: item.id } | ||||||
|         name: item.name, |       }); | ||||||
|         description: item.description, |  | ||||||
|         image: null, |  | ||||||
|         imageUrl: item.imageUrl, |  | ||||||
|         isActive: item.isActive, |  | ||||||
|         createdAt: item.createdAt |  | ||||||
|       } |  | ||||||
|       this.show_dialog = true |  | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     closeDialog() { |  | ||||||
|       this.show_dialog = false |  | ||||||
|       this.character = { |  | ||||||
|         id: null, |  | ||||||
|         name: '', |  | ||||||
|         description: '', |  | ||||||
|         image: null, |  | ||||||
|         imageUrl: '', |  | ||||||
|         isActive: true, |  | ||||||
|         createdAt: '' |  | ||||||
|       } |  | ||||||
|       this.selected_character = {} |  | ||||||
|     }, |  | ||||||
|  |  | ||||||
|     deleteConfirm(item) { |     deleteConfirm(item) { | ||||||
|       this.selected_character = item |       this.selected_character = item | ||||||
| @@ -341,68 +231,21 @@ export default { | |||||||
|       this.selected_character = {} |       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() { |     async deleteCharacter() { | ||||||
|       if (this.is_loading) return; |       if (this.is_loading) return; | ||||||
|       this.is_loading = true |       this.is_loading = true | ||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         // 여기에 API 호출 코드를 추가합니다. |         await apiDeleteCharacter(this.selected_character.id); | ||||||
|         // 예시: |         this.closeDeleteDialog(); | ||||||
|         // const res = await api.deleteCharacter(this.selected_character.id); |         this.notifySuccess('삭제되었습니다.'); | ||||||
|  |         await this.getCharacters(); | ||||||
|         // API 호출이 없으므로 임시로 성공 처리 |  | ||||||
|         setTimeout(() => { |  | ||||||
|           this.closeDeleteDialog() |  | ||||||
|           this.notifySuccess('삭제되었습니다.') |  | ||||||
|           this.getCharacters() |  | ||||||
|           this.is_loading = false |  | ||||||
|         }, 1000) |  | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') |         console.error('캐릭터 삭제 오류:', e); | ||||||
|         this.is_loading = false |         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||||
|  |       } finally { | ||||||
|  |         this.is_loading = false; | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
| @@ -418,38 +261,22 @@ export default { | |||||||
|     async getCharacters() { |     async getCharacters() { | ||||||
|       this.is_loading = true |       this.is_loading = true | ||||||
|       try { |       try { | ||||||
|         // 여기에 API 호출 코드를 추가합니다. |         const response = await getCharacterList(this.page); | ||||||
|         // 예시: |  | ||||||
|         // const res = await api.getCharacters(this.page); |  | ||||||
|  |  | ||||||
|         // API 호출이 없으므로 임시 데이터 생성 |         if (response && response.data) { | ||||||
|         setTimeout(() => { |           const data = response.data; | ||||||
|           // 임시 데이터 |           this.characters = data.items || []; | ||||||
|           const mockData = { |  | ||||||
|             totalCount: 15, |           const total_page = Math.ceil((data.totalCount || 0) / 10); | ||||||
|             items: Array.from({ length: 10 }, (_, i) => ({ |           this.total_page = total_page <= 0 ? 1 : total_page; | ||||||
|               id: i + 1 + (this.page - 1) * 10, |         } else { | ||||||
|               name: `캐릭터 ${i + 1 + (this.page - 1) * 10}`, |           throw new Error('응답 데이터가 없습니다.'); | ||||||
|               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] |  | ||||||
|             })) |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|           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) |  | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') |         console.error('캐릭터 목록 조회 오류:', e); | ||||||
|         this.is_loading = false |         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||||
|  |       } finally { | ||||||
|  |         this.is_loading = false; | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
| @@ -466,40 +293,22 @@ export default { | |||||||
|       } else { |       } else { | ||||||
|         this.is_loading = true |         this.is_loading = true | ||||||
|         try { |         try { | ||||||
|           // 여기에 API 호출 코드를 추가합니다. |           const response = await searchCharacters(this.search_word, this.page); | ||||||
|           // 예시: |  | ||||||
|           // const res = await api.searchCharacters(this.search_word, this.page); |  | ||||||
|  |  | ||||||
|           // API 호출이 없으므로 임시 데이터 생성 |           if (response && response.data) { | ||||||
|           setTimeout(() => { |             const data = response.data; | ||||||
|             // 검색 결과 임시 데이터 |             this.characters = data.items || []; | ||||||
|             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] |  | ||||||
|             })) |  | ||||||
|  |  | ||||||
|             const mockData = { |             const total_page = Math.ceil((data.totalCount || 0) / 10); | ||||||
|               totalCount: 3, |             this.total_page = total_page <= 0 ? 1 : total_page; | ||||||
|               items: filteredItems |           } else { | ||||||
|  |             throw new Error('응답 데이터가 없습니다.'); | ||||||
|           } |           } | ||||||
|  |  | ||||||
|             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) |  | ||||||
|         } catch (e) { |         } catch (e) { | ||||||
|           this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') |           console.error('캐릭터 검색 오류:', e); | ||||||
|           this.is_loading = false |           this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||||
|  |         } finally { | ||||||
|  |           this.is_loading = false; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user