feat(chat): 캐릭터 리스트, 추가/수정 폼, 배너

- response의 데이터 구조에 맞춰서 코드 수정
This commit is contained in:
Yu Sung 2025-08-12 21:09:08 +09:00
parent a3e82a81f8
commit ba248f7680
4 changed files with 119 additions and 61 deletions

View File

@ -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: {

View File

@ -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('배너 순서 변경에 실패했습니다.');

View File

@ -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);

View File

@ -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);