feat(original): 캐릭터 등록/수정
- 원작 등록/삭제 추가
This commit is contained in:
@@ -43,8 +43,7 @@ async function createCharacter(characterData) {
|
||||
gender: toNullIfBlank(characterData.gender),
|
||||
mbti: toNullIfBlank(characterData.mbti),
|
||||
characterType: toNullIfBlank(characterData.type),
|
||||
originalTitle: toNullIfBlank(characterData.originalTitle),
|
||||
originalLink: toNullIfBlank(characterData.originalLink),
|
||||
originalWorkId: characterData.originalWorkId || null,
|
||||
speechPattern: toNullIfBlank(characterData.speechPattern),
|
||||
speechStyle: toNullIfBlank(characterData.speechStyle),
|
||||
appearance: toNullIfBlank(characterData.appearance),
|
||||
|
@@ -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 = []) {
|
||||
return Vue.axios.post(`/admin/chat/original/${id}/assign-characters`, { characterIds })
|
||||
|
@@ -206,29 +206,64 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 원작 정보 -->
|
||||
<!-- 원작 선택 -->
|
||||
<v-row>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="character.originalTitle"
|
||||
label="원작명"
|
||||
<v-col cols="12">
|
||||
<v-autocomplete
|
||||
v-model="selectedOriginalId"
|
||||
:items="originalOptions"
|
||||
:loading="originalLoading"
|
||||
:search-input.sync="originalSearchTerm"
|
||||
item-text="title"
|
||||
item-value="id"
|
||||
label="원작 검색 후 선택"
|
||||
hide-no-data
|
||||
hide-selected
|
||||
clearable
|
||||
outlined
|
||||
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
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="character.originalLink"
|
||||
label="원작링크"
|
||||
outlined
|
||||
dense
|
||||
/>
|
||||
</v-row>
|
||||
<v-row v-if="selectedOriginal">
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar
|
||||
size="60"
|
||||
class="mr-3"
|
||||
>
|
||||
<v-img :src="selectedOriginal.imageUrl" />
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="subtitle-1">
|
||||
{{ selectedOriginal.title }}
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
small
|
||||
text
|
||||
@click="clearSelectedOriginal"
|
||||
>
|
||||
해제
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -1018,6 +1053,7 @@
|
||||
|
||||
<script>
|
||||
import {getCharacter, createCharacter, updateCharacter} from '@/api/character';
|
||||
import { searchOriginals } from '@/api/original';
|
||||
|
||||
export default {
|
||||
name: "CharacterForm",
|
||||
@@ -1055,6 +1091,13 @@ export default {
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
originalCharacter: null, // 원본 캐릭터 데이터 저장용
|
||||
// 원작 선택 상태
|
||||
selectedOriginalId: null,
|
||||
selectedOriginal: null,
|
||||
originalOptions: [],
|
||||
originalSearchTerm: '',
|
||||
originalLoading: false,
|
||||
originalDebounce: null,
|
||||
character: {
|
||||
id: null,
|
||||
name: '',
|
||||
@@ -1066,6 +1109,7 @@ export default {
|
||||
age: '',
|
||||
mbti: '',
|
||||
characterType: '',
|
||||
originalWorkId: null,
|
||||
originalTitle: '',
|
||||
originalLink: '',
|
||||
speechPattern: '',
|
||||
@@ -1165,6 +1209,14 @@ export default {
|
||||
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);
|
||||
},
|
||||
|
||||
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() {
|
||||
this.$router.push('/character');
|
||||
},
|
||||
@@ -1466,10 +1567,11 @@ export default {
|
||||
// 로드된 캐릭터 데이터에서 null을 빈 문자열로 변환 (UI 표시용)
|
||||
normalizeCharacterData(data) {
|
||||
const result = { ...data };
|
||||
// 기본값 보정
|
||||
if (result.originalWorkId == null) result.originalWorkId = null;
|
||||
const simpleFields = [
|
||||
'name', 'systemPrompt', 'description', 'age', 'gender', 'mbti',
|
||||
'characterType', 'originalTitle', 'originalLink', 'speechPattern',
|
||||
'speechStyle', 'appearance', 'imageUrl'
|
||||
'characterType', 'speechPattern', 'speechStyle', 'appearance', 'imageUrl'
|
||||
];
|
||||
simpleFields.forEach(f => {
|
||||
if (result[f] == null) result[f] = '';
|
||||
@@ -1489,8 +1591,7 @@ export default {
|
||||
gender: this.character.gender,
|
||||
mbti: this.character.mbti,
|
||||
characterType: this.character.characterType,
|
||||
originalTitle: this.character.originalTitle,
|
||||
originalLink: this.character.originalLink,
|
||||
originalWorkId: this.character.originalWorkId,
|
||||
speechPattern: this.character.speechPattern,
|
||||
speechStyle: this.character.speechStyle,
|
||||
appearance: this.character.appearance,
|
||||
@@ -1513,7 +1614,7 @@ export default {
|
||||
|
||||
// 기본 필드 비교
|
||||
const simpleFields = [
|
||||
'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalTitle', 'originalLink',
|
||||
'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalWorkId',
|
||||
'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;
|
||||
},
|
||||
|
||||
@@ -1586,6 +1696,18 @@ export default {
|
||||
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.memories = data.memories || [];
|
||||
@@ -1628,6 +1750,11 @@ export default {
|
||||
this.character.personalities = [...this.personalities];
|
||||
this.character.backgrounds = [...this.backgrounds];
|
||||
|
||||
// 선택된 원작 기준으로 originalWorkId 최종 반영
|
||||
if (this.selectedOriginalId !== null) {
|
||||
this.character.originalWorkId = this.selectedOriginalId;
|
||||
}
|
||||
|
||||
let response;
|
||||
|
||||
if (this.isEdit) {
|
||||
@@ -1750,8 +1877,7 @@ export default {
|
||||
gender: str(data.gender),
|
||||
mbti: str(data.mbti),
|
||||
characterType: str(data.characterType),
|
||||
originalTitle: str(data.originalTitle),
|
||||
originalLink: str(data.originalLink),
|
||||
originalWorkId: data.originalWorkId == null ? null : Number(data.originalWorkId),
|
||||
speechPattern: str(data.speechPattern),
|
||||
speechStyle: str(data.speechStyle),
|
||||
appearance: str(data.appearance)
|
||||
|
Reference in New Issue
Block a user