feat(original): 캐릭터 등록/수정

- 원작 등록/삭제 추가
This commit is contained in:
Yu Sung
2025-09-15 06:53:39 +09:00
parent 6507b025de
commit 00b12d0edb
3 changed files with 160 additions and 28 deletions

View File

@@ -43,8 +43,7 @@ async function createCharacter(characterData) {
gender: toNullIfBlank(characterData.gender), gender: toNullIfBlank(characterData.gender),
mbti: toNullIfBlank(characterData.mbti), mbti: toNullIfBlank(characterData.mbti),
characterType: toNullIfBlank(characterData.type), characterType: toNullIfBlank(characterData.type),
originalTitle: toNullIfBlank(characterData.originalTitle), originalWorkId: characterData.originalWorkId || null,
originalLink: toNullIfBlank(characterData.originalLink),
speechPattern: toNullIfBlank(characterData.speechPattern), speechPattern: toNullIfBlank(characterData.speechPattern),
speechStyle: toNullIfBlank(characterData.speechStyle), speechStyle: toNullIfBlank(characterData.speechStyle),
appearance: toNullIfBlank(characterData.appearance), appearance: toNullIfBlank(characterData.appearance),

View File

@@ -69,6 +69,13 @@ export async function getOriginalCharacters(id, page = 1, size = 20) {
}) })
} }
// 원작 검색
export async function searchOriginals(searchTerm) {
return Vue.axios.get('/admin/chat/original/search', {
params: { searchTerm }
})
}
// 원작에 캐릭터 연결 // 원작에 캐릭터 연결
export async function assignCharactersToOriginal(id, characterIds = []) { export async function assignCharactersToOriginal(id, characterIds = []) {
return Vue.axios.post(`/admin/chat/original/${id}/assign-characters`, { characterIds }) return Vue.axios.post(`/admin/chat/original/${id}/assign-characters`, { characterIds })

View File

@@ -206,29 +206,64 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- 원작 정보 --> <!-- 원작 선택 -->
<v-row> <v-row>
<v-col <v-col cols="12">
cols="12" <v-autocomplete
md="6" v-model="selectedOriginalId"
> :items="originalOptions"
<v-text-field :loading="originalLoading"
v-model="character.originalTitle" :search-input.sync="originalSearchTerm"
label="원작명" item-text="title"
item-value="id"
label="원작 검색 후 선택"
hide-no-data
hide-selected
clearable
outlined outlined
dense dense
/> @change="onOriginalChange"
>
<template v-slot:item="{ item, on, attrs }">
<v-list-item
v-bind="attrs"
v-on="on"
>
<v-list-item-avatar>
<v-img :src="item.imageUrl" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.title" />
<v-list-item-subtitle v-text="item.category" />
</v-list-item-content>
</v-list-item>
</template>
</v-autocomplete>
</v-col> </v-col>
<v-col </v-row>
cols="12" <v-row v-if="selectedOriginal">
md="6" <v-col cols="12">
<div class="d-flex align-center">
<v-avatar
size="60"
class="mr-3"
> >
<v-text-field <v-img :src="selectedOriginal.imageUrl" />
v-model="character.originalLink" </v-avatar>
label="원작링크" <div>
outlined <div class="subtitle-1">
dense {{ selectedOriginal.title }}
/> </div>
</div>
<v-spacer />
<v-btn
small
text
@click="clearSelectedOriginal"
>
해제
</v-btn>
</div>
</v-col> </v-col>
</v-row> </v-row>
@@ -1018,6 +1053,7 @@
<script> <script>
import {getCharacter, createCharacter, updateCharacter} from '@/api/character'; import {getCharacter, createCharacter, updateCharacter} from '@/api/character';
import { searchOriginals } from '@/api/original';
export default { export default {
name: "CharacterForm", name: "CharacterForm",
@@ -1055,6 +1091,13 @@ export default {
personalities: [], personalities: [],
backgrounds: [], backgrounds: [],
originalCharacter: null, // 원본 캐릭터 데이터 저장용 originalCharacter: null, // 원본 캐릭터 데이터 저장용
// 원작 선택 상태
selectedOriginalId: null,
selectedOriginal: null,
originalOptions: [],
originalSearchTerm: '',
originalLoading: false,
originalDebounce: null,
character: { character: {
id: null, id: null,
name: '', name: '',
@@ -1066,6 +1109,7 @@ export default {
age: '', age: '',
mbti: '', mbti: '',
characterType: '', characterType: '',
originalWorkId: null,
originalTitle: '', originalTitle: '',
originalLink: '', originalLink: '',
speechPattern: '', speechPattern: '',
@@ -1165,6 +1209,14 @@ export default {
this.previewImage = null; this.previewImage = null;
} }
} }
},
originalSearchTerm(val) {
if (this.originalDebounce) clearTimeout(this.originalDebounce);
if (!val || !val.trim()) {
this.originalOptions = [];
return;
}
this.originalDebounce = setTimeout(this.searchOriginalWorks, 300);
} }
}, },
@@ -1185,6 +1237,55 @@ export default {
this.$dialog.notify.success(message); this.$dialog.notify.success(message);
}, },
async searchOriginalWorks() {
try {
this.originalLoading = true;
const term = (this.originalSearchTerm || '').trim();
if (!term) {
this.originalOptions = [];
return;
}
const res = await searchOriginals(term);
if (res && res.status === 200 && res.data && res.data.success === true) {
const data = res.data.data;
const items = (data && data.content) ? data.content : (Array.isArray(data) ? data : []);
this.originalOptions = items || [];
} else {
this.originalOptions = [];
}
} catch (e) {
this.originalOptions = [];
} finally {
this.originalLoading = false;
}
},
onOriginalChange(val) {
if (!val) {
this.selectedOriginal = null;
this.selectedOriginalId = null;
this.character.originalWorkId = null;
return;
}
const id = Number(val);
const found = (this.originalOptions || []).find(o => Number(o.id) === id);
if (found) {
this.selectedOriginal = { id: Number(found.id), title: found.title, imageUrl: found.imageUrl };
} else if (this.selectedOriginal && Number(this.selectedOriginal.id) === id) {
// keep current selectedOriginal
} else {
this.selectedOriginal = { id };
}
this.selectedOriginalId = id;
this.character.originalWorkId = id;
},
clearSelectedOriginal() {
this.selectedOriginal = null;
this.selectedOriginalId = null;
this.character.originalWorkId = null;
},
goBack() { goBack() {
this.$router.push('/character'); this.$router.push('/character');
}, },
@@ -1466,10 +1567,11 @@ export default {
// 로드된 캐릭터 데이터에서 null을 빈 문자열로 변환 (UI 표시용) // 로드된 캐릭터 데이터에서 null을 빈 문자열로 변환 (UI 표시용)
normalizeCharacterData(data) { normalizeCharacterData(data) {
const result = { ...data }; const result = { ...data };
// 기본값 보정
if (result.originalWorkId == null) result.originalWorkId = null;
const simpleFields = [ const simpleFields = [
'name', 'systemPrompt', 'description', 'age', 'gender', 'mbti', 'name', 'systemPrompt', 'description', 'age', 'gender', 'mbti',
'characterType', 'originalTitle', 'originalLink', 'speechPattern', 'characterType', 'speechPattern', 'speechStyle', 'appearance', 'imageUrl'
'speechStyle', 'appearance', 'imageUrl'
]; ];
simpleFields.forEach(f => { simpleFields.forEach(f => {
if (result[f] == null) result[f] = ''; if (result[f] == null) result[f] = '';
@@ -1489,8 +1591,7 @@ export default {
gender: this.character.gender, gender: this.character.gender,
mbti: this.character.mbti, mbti: this.character.mbti,
characterType: this.character.characterType, characterType: this.character.characterType,
originalTitle: this.character.originalTitle, originalWorkId: this.character.originalWorkId,
originalLink: this.character.originalLink,
speechPattern: this.character.speechPattern, speechPattern: this.character.speechPattern,
speechStyle: this.character.speechStyle, speechStyle: this.character.speechStyle,
appearance: this.character.appearance, appearance: this.character.appearance,
@@ -1513,7 +1614,7 @@ export default {
// 기본 필드 비교 // 기본 필드 비교
const simpleFields = [ const simpleFields = [
'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalTitle', 'originalLink', 'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalWorkId',
'speechPattern', 'speechStyle', 'isActive' 'speechPattern', 'speechStyle', 'isActive'
]; ];
@@ -1563,6 +1664,15 @@ export default {
} }
}); });
// 특수 규칙: 기존에 원작이 연결되어 있었고, 해제(선택 제거)한 경우 서버 규약에 따라 0으로 전송
if (this.isEdit && ('originalWorkId' in changedFields)) {
const prev = this.originalCharacter && this.originalCharacter.originalWorkId;
const curr = changedFields.originalWorkId;
if ((curr === null || curr === undefined || curr === '') && (prev !== null && prev !== undefined && Number(prev) > 0)) {
changedFields.originalWorkId = 0;
}
}
return changedFields; return changedFields;
}, },
@@ -1586,6 +1696,18 @@ export default {
image: null // 파일 입력은 초기화 image: null // 파일 입력은 초기화
}; };
// 원작 선택 UI 반영
if (this.character.originalWork) {
const d = this.character.originalWork;
this.selectedOriginal = d ? { id: Number(d.id), title: d.title, imageUrl: d.imageUrl } : null;
this.selectedOriginalId = d ? Number(d.id) : null;
this.character.originalWorkId = d ? Number(d.id) : null;
this.originalCharacter.originalWorkId = d ? Number(d.id) : null;
} else {
this.selectedOriginal = null;
this.selectedOriginalId = null;
}
// 태그, 메모리, 인물관계, 취미, 가치관, 목표, 성격 특성, 세계관 설정 // 태그, 메모리, 인물관계, 취미, 가치관, 목표, 성격 특성, 세계관 설정
this.tags = data.tags || []; this.tags = data.tags || [];
this.memories = data.memories || []; this.memories = data.memories || [];
@@ -1628,6 +1750,11 @@ export default {
this.character.personalities = [...this.personalities]; this.character.personalities = [...this.personalities];
this.character.backgrounds = [...this.backgrounds]; this.character.backgrounds = [...this.backgrounds];
// 선택된 원작 기준으로 originalWorkId 최종 반영
if (this.selectedOriginalId !== null) {
this.character.originalWorkId = this.selectedOriginalId;
}
let response; let response;
if (this.isEdit) { if (this.isEdit) {
@@ -1750,8 +1877,7 @@ export default {
gender: str(data.gender), gender: str(data.gender),
mbti: str(data.mbti), mbti: str(data.mbti),
characterType: str(data.characterType), characterType: str(data.characterType),
originalTitle: str(data.originalTitle), originalWorkId: data.originalWorkId == null ? null : Number(data.originalWorkId),
originalLink: str(data.originalLink),
speechPattern: str(data.speechPattern), speechPattern: str(data.speechPattern),
speechStyle: str(data.speechStyle), speechStyle: str(data.speechStyle),
appearance: str(data.appearance) appearance: str(data.appearance)