Compare commits
3 Commits
a3e82a81f8
...
8f502f6d4d
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8f502f6d4d | ||
![]() |
38161af543 | ||
![]() |
ba248f7680 |
@@ -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
|
||||
@@ -270,6 +270,7 @@
|
||||
outlined
|
||||
auto-grow
|
||||
rows="4"
|
||||
:rules="systemPromptRules"
|
||||
/>
|
||||
<div
|
||||
class="caption grey--text text--darken-1 mt-1 custom-caption"
|
||||
@@ -908,10 +909,10 @@
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="isLoading"
|
||||
:disabled="!isFormValid || isLoading"
|
||||
:disabled="isSaveDisabled"
|
||||
@click="saveCharacter"
|
||||
>
|
||||
저장
|
||||
{{ isEdit ? '수정' : '저장' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
@@ -962,11 +963,11 @@ export default {
|
||||
gender: '',
|
||||
age: '',
|
||||
mbti: '',
|
||||
type: '',
|
||||
characterType: '',
|
||||
originalTitle: '',
|
||||
originalLink: '',
|
||||
speechPattern: '',
|
||||
conversationStyle: '',
|
||||
speechStyle: '',
|
||||
appearance: '',
|
||||
systemPrompt: '',
|
||||
tags: [],
|
||||
@@ -994,7 +995,7 @@ export default {
|
||||
v => (v && v.trim().length > 0) || '한 줄 소개를 입력하세요'
|
||||
],
|
||||
imageRules: [
|
||||
v => !this.isEdit || !!v || !!this.character.imageUrl || '이미지를 선택하세요'
|
||||
v => (this.isEdit ? true : (!!v || '이미지를 선택하세요'))
|
||||
],
|
||||
genderOptions: ['남성', '여성', '기타'],
|
||||
mbtiOptions: [
|
||||
@@ -1003,11 +1004,25 @@ export default {
|
||||
'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
|
||||
'ISTP', 'ISFP', 'ESTP', 'ESFP'
|
||||
],
|
||||
typeOptions: ['Clone', 'Character']
|
||||
typeOptions: ['Clone', 'Character'],
|
||||
systemPromptRules: [
|
||||
v => (this.isEdit ? true : (!!v && v.trim().length > 0) || '시스템 프롬프트를 입력하세요')
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isSaveDisabled() {
|
||||
if (this.isLoading) return true;
|
||||
if (!this.isFormValid) return true;
|
||||
if (!this.isEdit) return false; // 등록 시에는 변경 감지 없이 유효성만 확인
|
||||
|
||||
// 수정 시에는 변경 사항이 있는 경우에만 저장 가능
|
||||
const changed = this.getChangedFields();
|
||||
const hasNonIdField = Object.keys(changed || {}).some(k => k !== 'id');
|
||||
const imageChanged = !!this.character.image; // 새 이미지 선택 여부
|
||||
return !(hasNonIdField || imageChanged);
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -1277,6 +1292,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 +1324,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 +1349,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 +1408,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 +1476,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);
|
||||
|
@@ -19,23 +19,6 @@
|
||||
캐릭터 추가
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="search_word"
|
||||
label="캐릭터 이름 검색"
|
||||
@keyup.enter="search"
|
||||
>
|
||||
<v-btn
|
||||
slot="append"
|
||||
color="#9970ff"
|
||||
dark
|
||||
@click="search"
|
||||
>
|
||||
검색
|
||||
</v-btn>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col>
|
||||
@@ -251,7 +234,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCharacterList, searchCharacters, updateCharacter } from '@/api/character'
|
||||
import { getCharacterList, updateCharacter } from '@/api/character'
|
||||
|
||||
export default {
|
||||
name: "CharacterList",
|
||||
@@ -266,7 +249,6 @@ export default {
|
||||
detail_title: '',
|
||||
page: 1,
|
||||
total_page: 0,
|
||||
search_word: '',
|
||||
characters: [],
|
||||
selected_character: {}
|
||||
}
|
||||
@@ -367,12 +349,7 @@ export default {
|
||||
},
|
||||
|
||||
async next() {
|
||||
if (this.search_word.length < 2) {
|
||||
this.search_word = ''
|
||||
await this.getCharacters()
|
||||
} else {
|
||||
await this.searchCharacters()
|
||||
}
|
||||
},
|
||||
|
||||
async getCharacters() {
|
||||
@@ -380,8 +357,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 +367,9 @@ export default {
|
||||
} else {
|
||||
this.notifyError('응답 데이터가 없습니다.');
|
||||
}
|
||||
} else {
|
||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('캐릭터 목록 조회 오류:', e);
|
||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
@@ -397,38 +378,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async search() {
|
||||
this.page = 1
|
||||
await this.searchCharacters()
|
||||
},
|
||||
|
||||
async searchCharacters() {
|
||||
if (this.search_word.length === 0) {
|
||||
await this.getCharacters()
|
||||
} else if (this.search_word.length < 2) {
|
||||
this.notifyError('검색어를 2글자 이상 입력하세요.')
|
||||
} else {
|
||||
this.is_loading = true
|
||||
try {
|
||||
const response = await searchCharacters(this.search_word, this.page);
|
||||
|
||||
if (response && response.data) {
|
||||
const data = response.data;
|
||||
this.characters = data.content || [];
|
||||
|
||||
const total_page = Math.ceil((data.totalCount || 0) / 20);
|
||||
this.total_page = total_page <= 0 ? 1 : total_page;
|
||||
} else {
|
||||
this.notifyError('응답 데이터가 없습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('캐릭터 검색 오류:', e);
|
||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||
} finally {
|
||||
this.is_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
Reference in New Issue
Block a user