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