캐릭터 챗봇 #74
| @@ -19,6 +19,14 @@ async function getCharacter(id) { | ||||
|   return Vue.axios.get(`/admin/chat/character/${id}`) | ||||
| } | ||||
|  | ||||
| // 내부 헬퍼: 빈 문자열을 null로 변환 | ||||
| function toNullIfBlank(value) { | ||||
|   if (typeof value === 'string') { | ||||
|     return value.trim() === '' ? null : value; | ||||
|   } | ||||
|   return value === '' ? null : value; | ||||
| } | ||||
|  | ||||
| // 캐릭터 등록 | ||||
| async function createCharacter(characterData) { | ||||
|   const formData = new FormData() | ||||
| @@ -28,18 +36,18 @@ async function createCharacter(characterData) { | ||||
|  | ||||
|   // 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가 | ||||
|   const requestData = { | ||||
|     name: characterData.name, | ||||
|     systemPrompt: characterData.systemPrompt, | ||||
|     description: characterData.description, | ||||
|     age: characterData.age, | ||||
|     gender: characterData.gender, | ||||
|     mbti: characterData.mbti, | ||||
|     characterType: characterData.type, | ||||
|     originalTitle: characterData.originalTitle, | ||||
|     originalLink: characterData.originalLink, | ||||
|     speechPattern: characterData.speechPattern, | ||||
|     speechStyle: characterData.conversationStyle, | ||||
|     appearance: characterData.appearance, | ||||
|     name: toNullIfBlank(characterData.name), | ||||
|     systemPrompt: toNullIfBlank(characterData.systemPrompt), | ||||
|     description: toNullIfBlank(characterData.description), | ||||
|     age: toNullIfBlank(characterData.age), | ||||
|     gender: toNullIfBlank(characterData.gender), | ||||
|     mbti: toNullIfBlank(characterData.mbti), | ||||
|     characterType: toNullIfBlank(characterData.type), | ||||
|     originalTitle: toNullIfBlank(characterData.originalTitle), | ||||
|     originalLink: toNullIfBlank(characterData.originalLink), | ||||
|     speechPattern: toNullIfBlank(characterData.speechPattern), | ||||
|     speechStyle: toNullIfBlank(characterData.speechStyle), | ||||
|     appearance: toNullIfBlank(characterData.appearance), | ||||
|     tags: characterData.tags || [], | ||||
|     hobbies: characterData.hobbies || [], | ||||
|     values: characterData.values || [], | ||||
| @@ -66,9 +74,18 @@ async function updateCharacter(characterData, image = null) { | ||||
|   // 이미지가 있는 경우에만 FormData에 추가 | ||||
|   if (image) formData.append('image', image) | ||||
|  | ||||
|   // 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 | ||||
|   // 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환) | ||||
|   // characterData는 이미 변경된 필드만 포함하고 있음 | ||||
|   formData.append('request', JSON.stringify(characterData)) | ||||
|   const processed = {} | ||||
|   Object.keys(characterData).forEach(key => { | ||||
|     const value = characterData[key] | ||||
|     if (typeof value === 'string' || value === '') { | ||||
|       processed[key] = toNullIfBlank(value) | ||||
|     } else { | ||||
|       processed[key] = value | ||||
|     } | ||||
|   }) | ||||
|   formData.append('request', JSON.stringify(processed)) | ||||
|  | ||||
|   return Vue.axios.put(`/admin/chat/character/update`, formData, { | ||||
|     headers: { | ||||
|   | ||||
| @@ -197,7 +197,9 @@ | ||||
|                       </v-avatar> | ||||
|                     </v-col> | ||||
|                     <v-col> | ||||
|                       <div class="font-weight-medium">선택된 캐릭터: {{ selectedCharacter.name }}</div> | ||||
|                       <div class="font-weight-medium"> | ||||
|                         선택된 캐릭터: {{ selectedCharacter.name }} | ||||
|                       </div> | ||||
|                     </v-col> | ||||
|                   </v-row> | ||||
|                 </v-alert> | ||||
| @@ -356,8 +358,9 @@ export default { | ||||
|       try { | ||||
|         const response = await getCharacterBannerList(this.page); | ||||
|  | ||||
|         if (response && response.data) { | ||||
|           const newBanners = response.data.content || []; | ||||
|         if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|           const data = response.data.data; | ||||
|           const newBanners = data.content || []; | ||||
|           this.banners = [...this.banners, ...newBanners]; | ||||
|  | ||||
|           // 더 불러올 데이터가 있는지 확인 | ||||
| @@ -458,8 +461,9 @@ export default { | ||||
|       try { | ||||
|         const response = await searchCharacters(this.searchKeyword); | ||||
|  | ||||
|         if (response && response.data) { | ||||
|           this.searchResults = response.data.content || []; | ||||
|         if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|           const data = response.data.data; | ||||
|           this.searchResults = data.content || []; | ||||
|           this.searchPerformed = true; | ||||
|         } | ||||
|       } catch (error) { | ||||
| @@ -482,19 +486,27 @@ export default { | ||||
|       try { | ||||
|         if (this.isEdit) { | ||||
|           // 배너 수정 | ||||
|           await updateCharacterBanner({ | ||||
|           const response = await updateCharacterBanner({ | ||||
|             image: this.bannerForm.image, | ||||
|             characterId: this.selectedCharacter.id, | ||||
|             bannerId: this.bannerForm.bannerId | ||||
|           }); | ||||
|           if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|             this.notifySuccess('배너가 수정되었습니다.'); | ||||
|           } else { | ||||
|             this.notifyError('배너 수정을 실패했습니다.'); | ||||
|           } | ||||
|         } else { | ||||
|           // 배너 추가 | ||||
|           await createCharacterBanner({ | ||||
|           const response = await createCharacterBanner({ | ||||
|             image: this.bannerForm.image, | ||||
|             characterId: this.selectedCharacter.id | ||||
|           }); | ||||
|           if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|             this.notifySuccess('배너가 추가되었습니다.'); | ||||
|           } else { | ||||
|             this.notifyError('배너 추가를 실패했습니다.'); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         // 다이얼로그 닫고 배너 목록 새로고침 | ||||
| @@ -514,10 +526,14 @@ export default { | ||||
|       this.isSubmitting = true; | ||||
|  | ||||
|       try { | ||||
|         await deleteCharacterBanner(this.selectedBanner.id); | ||||
|         const response = await deleteCharacterBanner(this.selectedBanner.id); | ||||
|         if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|           this.notifySuccess('배너가 삭제되었습니다.'); | ||||
|           this.showDeleteDialog = false; | ||||
|           this.refreshBanners(); | ||||
|         } else { | ||||
|           this.notifyError('배너 삭제에 실패했습니다.'); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         console.error('배너 삭제 오류:', error); | ||||
|         this.notifyError('배너 삭제에 실패했습니다.'); | ||||
| @@ -538,8 +554,12 @@ export default { | ||||
|       // 드래그 앤 드롭으로 순서 변경 후 API 호출 | ||||
|       try { | ||||
|         const bannerIds = this.banners.map(banner => banner.id); | ||||
|         await updateCharacterBannerOrder(bannerIds); | ||||
|         const response = await updateCharacterBannerOrder(bannerIds); | ||||
|         if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|           this.notifySuccess('배너 순서가 변경되었습니다.'); | ||||
|         } else { | ||||
|           this.notifyError('배너 순서 변경에 실패했습니다.'); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         console.error('배너 순서 변경 오류:', error); | ||||
|         this.notifyError('배너 순서 변경에 실패했습니다.'); | ||||
|   | ||||
| @@ -135,7 +135,7 @@ | ||||
|                 md="6" | ||||
|               > | ||||
|                 <v-select | ||||
|                   v-model="character.type" | ||||
|                   v-model="character.characterType" | ||||
|                   :items="typeOptions" | ||||
|                   label="캐릭터 유형" | ||||
|                   outlined | ||||
| @@ -235,7 +235,7 @@ | ||||
|             <v-row> | ||||
|               <v-col cols="12"> | ||||
|                 <v-textarea | ||||
|                   v-model="character.conversationStyle" | ||||
|                   v-model="character.speechStyle" | ||||
|                   label="대화 스타일 (최대 200자)" | ||||
|                   outlined | ||||
|                   auto-grow | ||||
| @@ -962,11 +962,11 @@ export default { | ||||
|         gender: '', | ||||
|         age: '', | ||||
|         mbti: '', | ||||
|         type: '', | ||||
|         characterType: '', | ||||
|         originalTitle: '', | ||||
|         originalLink: '', | ||||
|         speechPattern: '', | ||||
|         conversationStyle: '', | ||||
|         speechStyle: '', | ||||
|         appearance: '', | ||||
|         systemPrompt: '', | ||||
|         tags: [], | ||||
| @@ -1277,6 +1277,27 @@ export default { | ||||
|       this.backgrounds.splice(index, 1); | ||||
|     }, | ||||
|  | ||||
|     // 공백과 null을 동일하게 취급하는 비교 함수 | ||||
|     areEqualConsideringBlankNull(a, b) { | ||||
|       const a1 = (a === null || a === undefined) ? '' : a; | ||||
|       const b1 = (b === null || b === undefined) ? '' : b; | ||||
|       return a1 === b1; | ||||
|     }, | ||||
|  | ||||
|     // 로드된 캐릭터 데이터에서 null을 빈 문자열로 변환 (UI 표시용) | ||||
|     normalizeCharacterData(data) { | ||||
|       const result = { ...data }; | ||||
|       const simpleFields = [ | ||||
|         'name', 'systemPrompt', 'description', 'age', 'gender', 'mbti', | ||||
|         'characterType', 'originalTitle', 'originalLink', 'speechPattern', | ||||
|         'speechStyle', 'appearance', 'imageUrl' | ||||
|       ]; | ||||
|       simpleFields.forEach(f => { | ||||
|         if (result[f] == null) result[f] = ''; | ||||
|       }); | ||||
|       return result; | ||||
|     }, | ||||
|  | ||||
|     // 변경된 필드만 추출하는 함수 | ||||
|     getChangedFields() { | ||||
|       if (!this.originalCharacter || !this.isEdit) { | ||||
| @@ -1288,11 +1309,11 @@ export default { | ||||
|           age: this.character.age, | ||||
|           gender: this.character.gender, | ||||
|           mbti: this.character.mbti, | ||||
|           type: this.character.type, | ||||
|           characterType: this.character.characterType, | ||||
|           originalTitle: this.character.originalTitle, | ||||
|           originalLink: this.character.originalLink, | ||||
|           speechPattern: this.character.speechPattern, | ||||
|           speechStyle: this.character.conversationStyle, | ||||
|           speechStyle: this.character.speechStyle, | ||||
|           appearance: this.character.appearance, | ||||
|           tags: this.character.tags || [], | ||||
|           hobbies: this.character.hobbies || [], | ||||
| @@ -1313,26 +1334,21 @@ export default { | ||||
|  | ||||
|       // 기본 필드 비교 | ||||
|       const simpleFields = [ | ||||
|         'name', 'description', 'age', 'gender', 'mbti', 'type', 'originalTitle', 'originalLink', | ||||
|         'speechPattern', 'isActive' | ||||
|         'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalTitle', 'originalLink', | ||||
|         'speechPattern', 'speechStyle', 'isActive' | ||||
|       ]; | ||||
|  | ||||
|       simpleFields.forEach(field => { | ||||
|         if (this.character[field] !== this.originalCharacter[field]) { | ||||
|         if (!this.areEqualConsideringBlankNull(this.character[field], this.originalCharacter[field])) { | ||||
|           changedFields[field] = this.character[field]; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       // 특수 필드 매핑 처리 (conversationStyle은 API에서 speechStyle로 사용됨) | ||||
|       if (this.character.conversationStyle !== this.originalCharacter.conversationStyle) { | ||||
|         changedFields.speechStyle = this.character.conversationStyle; | ||||
|       } | ||||
|  | ||||
|       if (this.character.systemPrompt !== this.originalCharacter.systemPrompt) { | ||||
|       if (!this.areEqualConsideringBlankNull(this.character.systemPrompt, this.originalCharacter.systemPrompt)) { | ||||
|         changedFields.systemPrompt = this.character.systemPrompt; | ||||
|       } | ||||
|  | ||||
|       if (this.character.appearance !== this.originalCharacter.appearance) { | ||||
|       if (!this.areEqualConsideringBlankNull(this.character.appearance, this.originalCharacter.appearance)) { | ||||
|         changedFields.appearance = this.character.appearance; | ||||
|       } | ||||
|  | ||||
| @@ -1377,16 +1393,17 @@ export default { | ||||
|         const response = await getCharacter(id); | ||||
|  | ||||
|         // API 응답에서 캐릭터 정보 설정 | ||||
|         if (response && response.success === true && response.data) { | ||||
|           const data = response.data; | ||||
|         if (response && response.status === 200 && response.data.success === true) { | ||||
|           const data = response.data.data; | ||||
|  | ||||
|           // 원본 데이터 저장 (깊은 복사) | ||||
|           this.originalCharacter = JSON.parse(JSON.stringify(data)); | ||||
|  | ||||
|           // 기본 데이터 설정 | ||||
|           // 기본 데이터 설정 (null 값을 UI 표시를 위해 빈 문자열로 변환) | ||||
|           const normalized = this.normalizeCharacterData(data); | ||||
|           this.character = { | ||||
|             ...this.character, // 기본 구조 유지 | ||||
|             ...data,  // API 응답 데이터로 덮어쓰기 | ||||
|             ...normalized,  // API 응답 데이터(정규화)로 덮어쓰기 | ||||
|             image: null        // 파일 입력은 초기화 | ||||
|           }; | ||||
|  | ||||
| @@ -1444,11 +1461,11 @@ export default { | ||||
|           response = await createCharacter(characterWithoutId); | ||||
|         } | ||||
|  | ||||
|         if (response && response.success === true && response.data) { | ||||
|         if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|             this.notifySuccess(this.isEdit ? '캐릭터가 수정되었습니다.' : '캐릭터가 등록되었습니다.'); | ||||
|             this.goBack(); | ||||
|         } else { | ||||
|           this.notifyError('응답 데이터가 없습니다.'); | ||||
|           this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error('캐릭터 저장 오류:', e); | ||||
|   | ||||
| @@ -380,8 +380,9 @@ export default { | ||||
|       try { | ||||
|         const response = await getCharacterList(this.page); | ||||
|  | ||||
|         if (response && response.data) { | ||||
|           const data = response.data; | ||||
|         if (response && response.status === 200) { | ||||
|           if (response.data.success === true) { | ||||
|             const data = response.data.data; | ||||
|             this.characters = data.content || []; | ||||
|  | ||||
|             const total_page = Math.ceil((data.totalCount || 0) / 20); | ||||
| @@ -389,6 +390,9 @@ export default { | ||||
|           } else { | ||||
|             this.notifyError('응답 데이터가 없습니다.'); | ||||
|           } | ||||
|         } else { | ||||
|           this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
|         } | ||||
|       } catch (e) { | ||||
|         console.error('캐릭터 목록 조회 오류:', e); | ||||
|         this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); | ||||
| @@ -412,8 +416,8 @@ export default { | ||||
|         try { | ||||
|           const response = await searchCharacters(this.search_word, this.page); | ||||
|  | ||||
|           if (response && response.data) { | ||||
|             const data = response.data; | ||||
|           if (response && response.status === 200 && response.data && response.data.success === true) { | ||||
|             const data = response.data.data; | ||||
|             this.characters = data.content || []; | ||||
|  | ||||
|             const total_page = Math.ceil((data.totalCount || 0) / 20); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user