Compare commits

...

3 Commits

Author SHA1 Message Date
Yu Sung
8f502f6d4d fix(chat): 캐릭터 추가/수정 폼 저장 버튼 로직 및 유효성 수정
- 수정 모드 이미지 변경 강제 제거, 시스템 프롬프트 필수 규칙 추가, 저장 버튼 라벨 조건부 표기(저장/수정)
- 수정 모드: 변경사항 또는 새 이미지 선택 시에만 저장 활성화, 등록 모드: 유효성만 충족 시 저장 가능
- 왜: 수정 UX 개선 및 필수 입력 요건 충족
2025-08-12 22:19:46 +09:00
Yu Sung
38161af543 feat(chat): 캐릭터 리스트
- 검색창 제거
2025-08-12 21:53:20 +09:00
Yu Sung
ba248f7680 feat(chat): 캐릭터 리스트, 추가/수정 폼, 배너
- response의 데이터 구조에 맞춰서 코드 수정
2025-08-12 21:09:08 +09:00
4 changed files with 138 additions and 120 deletions

View File

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

View File

@@ -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
}); });
this.notifySuccess('배너가 수정되었습니다.'); if (response && response.status === 200 && response.data && response.data.success === true) {
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
}); });
this.notifySuccess('배너가 추가되었습니다.'); 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; this.isSubmitting = true;
try { try {
await deleteCharacterBanner(this.selectedBanner.id); const response = await deleteCharacterBanner(this.selectedBanner.id);
this.notifySuccess('배너가 삭제되었습니다.'); if (response && response.status === 200 && response.data && response.data.success === true) {
this.showDeleteDialog = false; this.notifySuccess('배너가 삭제되었습니다.');
this.refreshBanners(); this.showDeleteDialog = false;
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);
this.notifySuccess('배너 순서가 변경되었습니다.'); if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너 순서가 변경되었습니다.');
} else {
this.notifyError('배너 순서 변경에 실패했습니다.');
}
} catch (error) { } catch (error) {
console.error('배너 순서 변경 오류:', error); console.error('배너 순서 변경 오류:', error);
this.notifyError('배너 순서 변경에 실패했습니다.'); this.notifyError('배너 순서 변경에 실패했습니다.');

View File

@@ -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
@@ -270,6 +270,7 @@
outlined outlined
auto-grow auto-grow
rows="4" rows="4"
:rules="systemPromptRules"
/> />
<div <div
class="caption grey--text text--darken-1 mt-1 custom-caption" class="caption grey--text text--darken-1 mt-1 custom-caption"
@@ -908,10 +909,10 @@
<v-btn <v-btn
color="primary" color="primary"
:loading="isLoading" :loading="isLoading"
:disabled="!isFormValid || isLoading" :disabled="isSaveDisabled"
@click="saveCharacter" @click="saveCharacter"
> >
저장 {{ isEdit ? '수정' : '저장' }}
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-form> </v-form>
@@ -962,11 +963,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: [],
@@ -994,7 +995,7 @@ export default {
v => (v && v.trim().length > 0) || '한 줄 소개를 입력하세요' v => (v && v.trim().length > 0) || '한 줄 소개를 입력하세요'
], ],
imageRules: [ imageRules: [
v => !this.isEdit || !!v || !!this.character.imageUrl || '이미지를 선택하세요' v => (this.isEdit ? true : (!!v || '이미지를 선택하세요'))
], ],
genderOptions: ['남성', '여성', '기타'], genderOptions: ['남성', '여성', '기타'],
mbtiOptions: [ mbtiOptions: [
@@ -1003,11 +1004,25 @@ export default {
'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
'ISTP', 'ISFP', 'ESTP', 'ESFP' 'ISTP', 'ISFP', 'ESTP', 'ESFP'
], ],
typeOptions: ['Clone', 'Character'] typeOptions: ['Clone', 'Character'],
systemPromptRules: [
v => (this.isEdit ? true : (!!v && v.trim().length > 0) || '시스템 프롬프트를 입력하세요')
]
} }
}, },
computed: { 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: { watch: {
@@ -1277,6 +1292,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 +1324,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 +1349,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 +1408,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 +1476,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);

View File

@@ -19,23 +19,6 @@
캐릭터 추가 캐릭터 추가
</v-btn> </v-btn>
</v-col> </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-row> <v-row>
<v-col> <v-col>
@@ -251,7 +234,7 @@
</template> </template>
<script> <script>
import { getCharacterList, searchCharacters, updateCharacter } from '@/api/character' import { getCharacterList, updateCharacter } from '@/api/character'
export default { export default {
name: "CharacterList", name: "CharacterList",
@@ -266,7 +249,6 @@ export default {
detail_title: '', detail_title: '',
page: 1, page: 1,
total_page: 0, total_page: 0,
search_word: '',
characters: [], characters: [],
selected_character: {} selected_character: {}
} }
@@ -367,12 +349,7 @@ export default {
}, },
async next() { async next() {
if (this.search_word.length < 2) { await this.getCharacters()
this.search_word = ''
await this.getCharacters()
} else {
await this.searchCharacters()
}
}, },
async getCharacters() { async getCharacters() {
@@ -380,14 +357,18 @@ 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) {
this.characters = data.content || []; const data = response.data.data;
this.characters = data.content || [];
const total_page = Math.ceil((data.totalCount || 0) / 20); const total_page = Math.ceil((data.totalCount || 0) / 20);
this.total_page = total_page <= 0 ? 1 : total_page; this.total_page = total_page <= 0 ? 1 : total_page;
} else {
this.notifyError('응답 데이터가 없습니다.');
}
} else { } else {
this.notifyError('응답 데이터가 없습니다.'); this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
} }
} catch (e) { } catch (e) {
console.error('캐릭터 목록 조회 오류:', e); console.error('캐릭터 목록 조회 오류:', e);
@@ -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> </script>