sodalive-vuejs-admin/src/views/Chat/CharacterForm.vue

1677 lines
55 KiB
Vue

<template>
<div>
<v-toolbar dark>
<v-btn
icon
@click="goBack"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-spacer />
<v-toolbar-title>{{ isEdit ? '캐릭터 수정' : '캐릭터 등록' }}</v-toolbar-title>
<v-spacer />
</v-toolbar>
<v-container>
<v-card class="pa-4">
<v-form
ref="form"
v-model="isFormValid"
>
<v-card-text>
<!-- 이미지 -->
<v-row>
<v-col
cols="12"
md="6"
>
<v-file-input
v-model="character.image"
label="캐릭터 이미지"
accept="image/*"
prepend-icon="mdi-camera"
show-size
truncate-length="15"
outlined
dense
:class="{ 'required-asterisk': !isEdit }"
:rules="imageRules"
/>
</v-col>
<v-col
v-if="previewImage || character.imageUrl"
cols="12"
md="6"
>
<div class="text-center">
<v-avatar size="150">
<v-img
:src="previewImage || character.imageUrl"
alt="캐릭터 이미지"
contain
/>
</v-avatar>
</div>
</v-col>
</v-row>
<!-- 캐릭터명 -->
<v-row>
<v-col cols="12">
<v-text-field
v-model="character.name"
label="캐릭터명"
:rules="nameRules"
class="required-asterisk"
required
outlined
dense
/>
</v-col>
</v-row>
<!-- 캐릭터 한 줄 소개 -->
<v-row>
<v-col cols="12">
<v-text-field
v-model="character.description"
label="캐릭터 한 줄 소개"
:rules="descriptionRules"
class="required-asterisk"
required
outlined
dense
/>
</v-col>
</v-row>
<!-- 성별 -->
<v-row>
<v-col
cols="12"
md="6"
>
<v-select
v-model="character.gender"
:items="genderOptions"
label="성별"
outlined
dense
/>
</v-col>
<!-- 나이 -->
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="character.age"
label="나이"
type="number"
min="0"
outlined
dense
class="required-asterisk"
:rules="ageRules"
@input="validateNumberInput"
/>
</v-col>
</v-row>
<!-- MBTI & 캐릭터 유형 -->
<v-row>
<v-col
cols="12"
md="6"
>
<v-select
v-model="character.mbti"
:items="mbtiOptions"
label="MBTI"
outlined
dense
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-select
v-model="character.characterType"
:items="typeOptions"
label="캐릭터 유형"
outlined
dense
/>
</v-col>
</v-row>
<!-- 태그 - 입력 후 띄어쓰기 할 때마다 Chip 형태로 변경 -->
<v-row>
<v-col cols="12">
<v-combobox
v-model="tags"
label="태그"
multiple
chips
small-chips
deletable-chips
outlined
dense
:rules="tagRules"
@keydown.space.prevent="addTag"
>
<template v-slot:selection="{ attrs, item, select, selected }">
<v-chip
v-bind="attrs"
:input-value="selected"
close
@click="select"
@click:close="removeTag(item)"
>
{{ item }}
</v-chip>
</template>
</v-combobox>
<div
class="caption grey--text text--darken-1 custom-caption"
>
태그를 입력하고 엔터를 누르면 추가됩니다. (50자 이내, 최대 20개)
</div>
</v-col>
</v-row>
<!-- 원작 정보 -->
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="character.originalTitle"
label="원작명"
outlined
dense
/>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="character.originalLink"
label="원작링크"
outlined
dense
/>
</v-col>
</v-row>
<!-- 긴 텍스트 섹션 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-4">
상세 정보
</h3>
</v-col>
</v-row>
<!-- 말투/특징적 표현 -->
<v-row>
<v-col cols="12">
<v-textarea
v-model="character.speechPattern"
label="말투/특징적 표현 (최대 500자)"
outlined
auto-grow
rows="4"
counter="500"
:rules="[v => v.length <= 500 || '최대 500자까지 입력 가능합니다']"
/>
</v-col>
</v-row>
<!-- 대화 스타일 -->
<v-row>
<v-col cols="12">
<v-textarea
v-model="character.speechStyle"
label="대화 스타일 (최대 200자)"
outlined
auto-grow
rows="3"
counter="200"
:rules="[v => v.length <= 200 || '최대 200자까지 입력 가능합니다']"
/>
</v-col>
</v-row>
<!-- 외모 설명 -->
<v-row>
<v-col cols="12">
<v-textarea
v-model="character.appearance"
label="외모 설명 (최대 1000자)"
outlined
auto-grow
rows="5"
counter="1000"
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
/>
</v-col>
</v-row>
<!-- 시스템 프롬프트 -->
<v-row>
<v-col cols="12">
<v-textarea
v-model="character.systemPrompt"
label="시스템 프롬프트"
outlined
auto-grow
rows="4"
:class="{ 'required-asterisk': !isEdit }"
:rules="systemPromptRules"
/>
<div
class="caption grey--text text--darken-1 mt-1 custom-caption"
>
캐릭터의 행동 방식과 제약사항을 정의하는 시스템 프롬프트입니다.
</div>
</v-col>
</v-row>
<!-- 취미 목록 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
취미 목록
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="11">
<v-text-field
v-model="newHobby"
label="새 취미 추가 (최대 100자)"
outlined
dense
counter="100"
:rules="[v => v.length <= 100 || '최대 100자까지 입력 가능합니다']"
@keyup.enter="addHobby"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newHobby.trim() || hobbies.length >= 10"
@click="addHobby"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="hobbies.length > 0"
class="memory-list"
>
<v-list-item
v-for="(hobby, index) in hobbies"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text">
{{ hobby }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeHobby(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
취미가 없습니다. 위 입력창에서 취미를 추가해주세요. (최대 10개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 가치관 목록 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
가치관 목록
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="11">
<v-text-field
v-model="newValue"
label="새 가치관 추가 (최대 100자)"
outlined
dense
counter="100"
:rules="[v => v.length <= 100 || '최대 100자까지 입력 가능합니다']"
@keyup.enter="addValue"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newValue.trim() || values.length >= 10"
@click="addValue"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="values.length > 0"
class="memory-list"
>
<v-list-item
v-for="(value, index) in values"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text">
{{ value }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeValue(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
가치관이 없습니다. 위 입력창에서 가치관을 추가해주세요. (최대 10개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 목표 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
목표
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="11">
<v-text-field
v-model="newGoal"
label="새 목표 추가 (최대 200자)"
outlined
dense
counter="200"
:rules="[v => v.length <= 200 || '최대 200자까지 입력 가능합니다']"
@keyup.enter="addGoal"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newGoal.trim() || goals.length >= 10"
@click="addGoal"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="goals.length > 0"
class="memory-list"
>
<v-list-item
v-for="(goal, index) in goals"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text">
{{ goal }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeGoal(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
목표가 없습니다. 위 입력창에서 목표를 추가해주세요. (최대 10개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 세계관 (배경이야기) -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
세계관 (배경이야기)
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="5">
<v-text-field
v-model="newBackgroundTopic"
label="주제 (최대 100자)"
outlined
dense
counter="100"
:rules="[v => v.length <= 100 || '최대 100자까지 입력 가능합니다']"
@keyup.enter="addBackground"
/>
</v-col>
<v-col cols="6">
<v-textarea
v-model="newBackgroundDescription"
label="배경 설명 (최대 1000자)"
outlined
dense
auto-grow
rows="2"
counter="1000"
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newBackgroundTopic.trim() || !newBackgroundDescription.trim() || backgrounds.length >= 10"
@click="addBackground"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="backgrounds.length > 0"
class="memory-list"
>
<v-list-item
v-for="(background, index) in backgrounds"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text font-weight-bold">
{{ background.topic }}
</v-list-item-title>
<v-list-item-subtitle class="memory-text mt-1">
{{ background.description }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeBackground(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
세계관 정보가 없습니다. 위 입력창에서 세계관 정보를 추가해주세요. (최대 10개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 성격 특성 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
성격 특성
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="5">
<v-text-field
v-model="newPersonalityTrait"
label="특성 제목 (최대 100자)"
outlined
dense
counter="100"
:rules="[v => v.length <= 100 || '최대 100자까지 입력 가능합니다']"
@keyup.enter="addPersonality"
/>
</v-col>
<v-col cols="6">
<v-textarea
v-model="newPersonalityDescription"
label="특성 설명 (최대 500자)"
outlined
dense
auto-grow
rows="2"
counter="500"
:rules="[v => v.length <= 500 || '최대 500자까지 입력 가능합니다']"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newPersonalityTrait.trim() || !newPersonalityDescription.trim() || personalities.length >= 10"
@click="addPersonality"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="personalities.length > 0"
class="memory-list"
>
<v-list-item
v-for="(personality, index) in personalities"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text font-weight-bold">
{{ personality.trait }}
</v-list-item-title>
<v-list-item-subtitle class="memory-text mt-1">
{{ personality.description }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removePersonality(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
성격 특성이 없습니다. 위 입력창에서 성격 특성을 추가해주세요. (최대 10개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 기억/메모리 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
기억/메모리
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="4">
<v-text-field
v-model="newMemoryTitle"
label="제목 (최대 100자)"
outlined
dense
counter="100"
:rules="[v => v.length <= 100 || '최대 100자까지 입력 가능합니다']"
/>
</v-col>
<v-col cols="4">
<v-textarea
v-model="newMemoryContent"
label="기억 내용 (최대 1000자)"
outlined
dense
auto-grow
rows="2"
counter="1000"
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
/>
</v-col>
<v-col cols="3">
<v-text-field
v-model="newMemoryEmotion"
label="감정 (최대 50자)"
outlined
dense
counter="50"
:rules="[v => v.length <= 50 || '최대 50자까지 입력 가능합니다']"
@keyup.enter="addMemory"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newMemoryTitle.trim() || !newMemoryContent.trim() || memories.length >= 20"
@click="addMemory"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="memories.length > 0"
class="memory-list"
>
<v-list-item
v-for="(memory, index) in memories"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text font-weight-bold">
{{ memory.title }}
</v-list-item-title>
<v-list-item-subtitle class="memory-text mt-1">
{{ memory.content }}
</v-list-item-subtitle>
<v-list-item-subtitle
v-if="memory.emotion"
class="memory-text mt-1 font-italic"
>
감정: {{ memory.emotion }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeMemory(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
기억이 없습니다. 위 입력창에서 기억을 추가해주세요. (최대 20개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 인물 관계 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
인물 관계
</h3>
<v-row>
<v-col cols="12">
<!-- 1행: 상대방 이름, 관계명 -->
<v-row>
<v-col cols="4">
<v-text-field
v-model="newRelationship.personName"
label="상대방 이름 (최대 10자)"
outlined
dense
counter="10"
/>
</v-col>
<v-col cols="8">
<v-text-field
v-model="newRelationship.relationshipName"
label="관계명 (어머니, 아버지, 친구 등) (최대 20자)"
outlined
dense
counter="20"
/>
</v-col>
</v-row>
<!-- 2행: 관계 타입, 현재 상태, 중요도 -->
<v-row>
<v-col cols="5">
<v-text-field
v-model="newRelationship.relationshipType"
label="관계 타입 (가족, 친구, 동료, 연인, 기타 등) (최대 10자)"
outlined
dense
counter="10"
/>
</v-col>
<v-col cols="5">
<v-text-field
v-model="newRelationship.currentStatus"
label="현재 상태 (생존, 사망, 불명 등) (최대 10자)"
outlined
dense
counter="10"
/>
</v-col>
<v-col cols="2">
<v-text-field
v-model.number="newRelationship.importance"
label="중요도 (1~10)"
outlined
dense
type="number"
min="1"
max="10"
/>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row>
<v-col cols="11">
<v-textarea
v-model="newRelationship.description"
label="관계 설명 (최대 500자)"
outlined
dense
auto-grow
rows="2"
counter="500"
:rules="relationshipDescriptionRules"
@keyup.enter="addRelationship"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newRelationship.personName || !newRelationship.relationshipName || !newRelationship.relationshipType || !newRelationship.currentStatus || !newRelationship.importance || relationships.length >= 10"
@click="addRelationship"
>
추가
</v-btn>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="relationships.length > 0"
class="memory-list"
>
<v-list-item
v-for="(relationship, index) in relationships"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text font-weight-bold">
{{ relationship.personName }} - {{ relationship.relationshipName }}
</v-list-item-title>
<v-list-item-subtitle class="memory-text mt-1">
타입: {{ relationship.relationshipType }} | 상태: {{ relationship.currentStatus }} | 중요도: {{ relationship.importance }}
</v-list-item-subtitle>
<v-list-item-subtitle
v-if="relationship.description"
class="memory-text mt-1"
>
{{ relationship.description }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeRelationship(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
인물 관계가 없습니다. 위 입력창에서 인물 관계를 추가해주세요. (최대 10개)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 활성화 상태 -->
<v-row v-show="isEdit">
<v-col cols="12">
<v-divider class="my-4" />
<v-switch
v-model="character.isActive"
label="캐릭터 활성화"
color="primary"
hide-details
class="mt-2"
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="error"
text
:disabled="isLoading"
@click="goBack"
>
취소
</v-btn>
<v-btn
color="primary"
:loading="isLoading"
:disabled="isSaveDisabled"
@click="saveCharacter"
>
{{ isEdit ? '수정' : '저장' }}
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-container>
</div>
</template>
<script>
import {getCharacter, createCharacter, updateCharacter} from '@/api/character';
export default {
name: "CharacterForm",
data() {
return {
isLoading: false,
isFormValid: false,
isEdit: false,
previewImage: null,
newMemoryTitle: '',
newMemoryContent: '',
newMemoryEmotion: '',
newRelationship: {
personName: '',
relationshipName: '',
description: '',
importance: null,
relationshipType: '',
currentStatus: ''
},
newHobby: '',
newValue: '',
newGoal: '',
newPersonalityTrait: '',
newPersonalityDescription: '',
newBackgroundTopic: '',
newBackgroundDescription: '',
tags: [],
memories: [],
relationships: [],
hobbies: [],
values: [],
goals: [],
personalities: [],
backgrounds: [],
originalCharacter: null, // 원본 캐릭터 데이터 저장용
character: {
id: null,
name: '',
description: '',
image: null,
imageUrl: '',
isActive: true,
gender: '',
age: '',
mbti: '',
characterType: '',
originalTitle: '',
originalLink: '',
speechPattern: '',
speechStyle: '',
appearance: '',
systemPrompt: '',
tags: [],
memories: [],
relationships: [],
hobbies: [],
values: [],
goals: [],
personalities: [],
backgrounds: []
},
ageRules: [
v => !!v || '나이를 입력하세요',
v => (v && !isNaN(v) && parseInt(v) >= 0) || '유효한 나이를 입력하세요'
],
tagRules: [
v => v.length <= 20 || '태그는 최대 20개까지 등록 가능합니다'
],
nameRules: [
v => !!v || '이름을 입력하세요',
v => (v && v.trim().length > 0) || '이름을 입력하세요'
],
descriptionRules: [
v => !!v || '한 줄 소개를 입력하세요',
v => (v && v.trim().length > 0) || '한 줄 소개를 입력하세요'
],
imageRules: [
v => (this.isEdit ? true : (!!v || '이미지를 선택하세요'))
],
genderOptions: ['남성', '여성', '기타'],
mbtiOptions: [
'INTJ', 'INTP', 'ENTJ', 'ENTP',
'INFJ', 'INFP', 'ENFJ', 'ENFP',
'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
'ISTP', 'ISFP', 'ESTP', 'ESFP'
],
typeOptions: ['Clone', 'Character'],
systemPromptRules: [
v => (this.isEdit ? true : (!!v && v.trim().length > 0) || '시스템 프롬프트를 입력하세요')
],
// 인물 관계 옵션 및 검증 규칙
relationshipTypeOptions: ['가족', '친구', '동료', '연인', '기타'],
relationshipStatusOptions: ['생존', '사망', '불명'],
personNameRules: [
v => (v === '' || v != null) || '상대방 이름을 입력하세요',
v => (!v || (typeof v === 'string' && v.length <= 10)) || '최대 10자까지 입력 가능합니다',
v => (!!v && v.trim().length > 0) || '상대방 이름을 입력하세요'
],
relationshipNameRules: [
v => (!!v && v.trim().length > 0) || '관계명을 입력하세요',
v => (typeof v === 'string' && v.length <= 20) || '최대 20자까지 입력 가능합니다'
],
relationshipDescriptionRules: [
v => (!v || (typeof v === 'string' && v.length <= 500)) || '최대 500자까지 입력 가능합니다'
],
relationshipImportanceRules: [
v => (v !== null && v !== '' && !isNaN(v)) || '중요도를 입력하세요',
v => (parseInt(v) >= 1 && parseInt(v) <= 10) || '1~10 사이 숫자만 입력 가능합니다'
],
relationshipTypeRules: [
v => (!!v && v.trim().length > 0) || '관계 타입을 입력하세요',
v => (typeof v === 'string' && v.length <= 10) || '최대 10자까지 입력 가능합니다'
],
relationshipStatusRules: [
v => (!!v && v.trim().length > 0) || '현재 상태를 입력하세요',
v => (typeof v === 'string' && v.length <= 10) || '최대 10자까지 입력 가능합니다'
]
}
},
computed: {
isSaveDisabled() {
if (this.isLoading) return true;
// 등록(create) 모드에서는 폼 유효성으로 판단
if (!this.isEdit) {
return !this.isFormValid;
}
// 수정(edit) 모드에서는 변경 사항이 있는지만 판단(필수값 유효성과 무관)
const changed = this.getChangedFields();
const hasNonIdField = Object.keys(changed || {}).some(k => k !== 'id');
const imageChanged = !!this.character.image; // 새 이미지 선택 여부
return !(hasNonIdField || imageChanged);
}
},
watch: {
'character.image': {
handler(newImage) {
if (newImage) {
this.createImagePreview(newImage);
} else {
this.previewImage = null;
}
}
}
},
created() {
// 수정 모드인지 확인
if (this.$route.query.id) {
this.isEdit = true;
this.loadCharacter(this.$route.query.id);
}
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message);
},
notifySuccess(message) {
this.$dialog.notify.success(message);
},
goBack() {
this.$router.push('/character');
},
validateNumberInput() {
// 숫자만 입력 가능하도록 처리
if (this.character.age && isNaN(this.character.age)) {
this.character.age = this.character.age.replace(/[^0-9]/g, '');
}
},
createImagePreview(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
this.previewImage = e.target.result;
};
reader.readAsDataURL(file);
},
addTag() {
const lastTag = this.tags[this.tags.length - 1];
if (lastTag && typeof lastTag === 'string' && lastTag.trim()) {
// 띄어쓰기 제거 및 50자 제한
let processedTag = lastTag.trim().replace(/\s+/g, '');
if (processedTag.length > 50) {
processedTag = processedTag.substring(0, 50);
}
// 태그 개수 제한 (20개)
if (this.tags.length <= 20) {
this.tags.splice(this.tags.length - 1, 1, processedTag);
this.$nextTick(() => {
this.tags.push('');
});
} else {
this.notifyError('태그는 최대 20개까지 등록 가능합니다.');
}
}
},
removeTag(item) {
this.tags.splice(this.tags.indexOf(item), 1);
},
addMemory() {
if (this.newMemoryTitle.trim() && this.newMemoryContent.trim()) {
if (this.memories.length >= 20) {
this.notifyError('기억은 최대 20개까지 등록 가능합니다.');
return;
}
// 글자수 제한 적용
let title = this.newMemoryTitle.trim();
let content = this.newMemoryContent.trim();
let emotion = this.newMemoryEmotion.trim();
if (title.length > 100) {
title = title.substring(0, 100);
}
if (content.length > 1000) {
content = content.substring(0, 1000);
}
if (emotion.length > 50) {
emotion = emotion.substring(0, 50);
}
// 새 기억 객체 생성 및 추가
this.memories.unshift({
title,
content,
emotion
});
// 입력 필드 초기화
this.newMemoryTitle = '';
this.newMemoryContent = '';
this.newMemoryEmotion = '';
}
},
removeMemory(index) {
this.memories.splice(index, 1);
},
addRelationship() {
const r = this.newRelationship;
// 필수값 검사
if (!r.personName || !r.relationshipName || !r.relationshipType || !r.currentStatus || r.importance === null || r.importance === '') {
this.notifyError('상대방 이름, 관계명, 관계 타입, 현재 상태, 중요도를 모두 입력하세요.');
return;
}
if (this.relationships.length >= 10) {
this.notifyError('인물 관계는 최대 10개까지 등록 가능합니다.');
return;
}
// 길이 제한 적용
let personName = r.personName.trim().substring(0, 10);
let relationshipName = r.relationshipName.trim().substring(0, 20);
let description = (r.description || '').trim().substring(0, 500);
// 중요도 범위 보정
let importance = parseInt(r.importance);
if (isNaN(importance)) importance = 1;
importance = Math.max(1, Math.min(10, importance));
// 타입/상태 길이 제한 적용
let relationshipType = (r.relationshipType || '').trim().substring(0, 10);
let currentStatus = (r.currentStatus || '').trim().substring(0, 10);
const relationshipObj = {
personName,
relationshipName,
description,
importance,
relationshipType,
currentStatus
};
this.relationships.unshift(relationshipObj);
// 입력 필드 초기화
this.newRelationship = {
personName: '',
relationshipName: '',
description: '',
importance: null,
relationshipType: '',
currentStatus: ''
};
},
removeRelationship(index) {
this.relationships.splice(index, 1);
},
addHobby() {
if (this.newHobby.trim()) {
if (this.hobbies.length >= 10) {
this.notifyError('취미는 최대 10개까지 등록 가능합니다.');
return;
}
if (this.newHobby.length > 100) {
this.newHobby = this.newHobby.substring(0, 100);
}
this.hobbies.unshift(this.newHobby.trim());
this.newHobby = '';
}
},
removeHobby(index) {
this.hobbies.splice(index, 1);
},
addValue() {
if (this.newValue.trim()) {
if (this.values.length >= 10) {
this.notifyError('가치관은 최대 10개까지 등록 가능합니다.');
return;
}
if (this.newValue.length > 100) {
this.newValue = this.newValue.substring(0, 100);
}
this.values.unshift(this.newValue.trim());
this.newValue = '';
}
},
removeValue(index) {
this.values.splice(index, 1);
},
addGoal() {
if (this.newGoal.trim()) {
if (this.goals.length >= 10) {
this.notifyError('목표는 최대 10개까지 등록 가능합니다.');
return;
}
if (this.newGoal.length > 200) {
this.newGoal = this.newGoal.substring(0, 200);
}
this.goals.unshift(this.newGoal.trim());
this.newGoal = '';
}
},
removeGoal(index) {
this.goals.splice(index, 1);
},
addPersonality() {
if (this.newPersonalityTrait.trim() && this.newPersonalityDescription.trim()) {
if (this.personalities.length >= 10) {
this.notifyError('성격 특성은 최대 10개까지 등록 가능합니다.');
return;
}
// 글자수 제한 적용
let trait = this.newPersonalityTrait.trim();
let description = this.newPersonalityDescription.trim();
if (trait.length > 100) {
trait = trait.substring(0, 100);
}
if (description.length > 500) {
description = description.substring(0, 500);
}
// 새 성격 특성 객체 생성 및 추가
this.personalities.unshift({
trait,
description
});
// 입력 필드 초기화
this.newPersonalityTrait = '';
this.newPersonalityDescription = '';
}
},
removePersonality(index) {
this.personalities.splice(index, 1);
},
addBackground() {
if (this.newBackgroundTopic.trim() && this.newBackgroundDescription.trim()) {
if (this.backgrounds.length >= 10) {
this.notifyError('세계관 정보는 최대 10개까지 등록 가능합니다.');
return;
}
// 글자수 제한 적용
let topic = this.newBackgroundTopic.trim();
let description = this.newBackgroundDescription.trim();
if (topic.length > 100) {
topic = topic.substring(0, 100);
}
if (description.length > 1000) {
description = description.substring(0, 1000);
}
// 새 세계관 객체 생성 및 추가
this.backgrounds.unshift({
topic,
description
});
// 입력 필드 초기화
this.newBackgroundTopic = '';
this.newBackgroundDescription = '';
}
},
removeBackground(index) {
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) {
// 신규 등록인 경우 또는 원본 데이터가 없는 경우 전체 데이터 반환
return {
name: this.character.name,
systemPrompt: this.character.systemPrompt,
description: this.character.description,
age: this.character.age,
gender: this.character.gender,
mbti: this.character.mbti,
characterType: this.character.characterType,
originalTitle: this.character.originalTitle,
originalLink: this.character.originalLink,
speechPattern: this.character.speechPattern,
speechStyle: this.character.speechStyle,
appearance: this.character.appearance,
tags: this.character.tags || [],
hobbies: this.character.hobbies || [],
values: this.character.values || [],
goals: this.character.goals || [],
relationships: this.character.relationships || [],
personalities: this.character.personalities || [],
backgrounds: this.character.backgrounds || [],
memories: this.character.memories || [],
isActive: this.character.isActive
};
}
// 변경된 필드만 포함하는 객체
const changedFields = {
id: this.character.id // ID는 항상 포함
};
// 기본 필드 비교
const simpleFields = [
'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalTitle', 'originalLink',
'speechPattern', 'speechStyle', 'isActive'
];
simpleFields.forEach(field => {
if (!this.areEqualConsideringBlankNull(this.character[field], this.originalCharacter[field])) {
changedFields[field] = this.character[field];
}
});
if (!this.areEqualConsideringBlankNull(this.character.systemPrompt, this.originalCharacter.systemPrompt)) {
changedFields.systemPrompt = this.character.systemPrompt;
}
if (!this.areEqualConsideringBlankNull(this.character.appearance, this.originalCharacter.appearance)) {
changedFields.appearance = this.character.appearance;
}
// 배열 필드 비교 (깊은 비교)
const arrayFields = [
'tags', 'hobbies', 'values', 'goals'
];
arrayFields.forEach(field => {
const original = this.originalCharacter[field] || [];
const current = this.character[field] || [];
// 길이가 다르거나 내용이 다른 경우
if (original.length !== current.length ||
JSON.stringify(original) !== JSON.stringify(current)) {
changedFields[field] = current;
}
});
// 복잡한 객체 배열 비교
const objectArrayFields = [
'personalities', 'backgrounds', 'memories', 'relationships'
];
objectArrayFields.forEach(field => {
const original = this.originalCharacter[field] || [];
const current = this.character[field] || [];
// 길이가 다르거나 내용이 다른 경우
if (original.length !== current.length ||
JSON.stringify(original) !== JSON.stringify(current)) {
changedFields[field] = current;
}
});
return changedFields;
},
async loadCharacter(id) {
this.isLoading = true;
try {
const response = await getCharacter(id);
// API 응답에서 캐릭터 정보 설정
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, // 기본 구조 유지
...normalized, // API 응답 데이터(정규화)로 덮어쓰기
image: null // 파일 입력은 초기화
};
// 태그, 메모리, 인물관계, 취미, 가치관, 목표, 성격 특성, 세계관 설정
this.tags = data.tags || [];
this.memories = data.memories || [];
this.relationships = data.relationships || [];
this.hobbies = data.hobbies || [];
this.values = data.values || [];
this.goals = data.goals || [];
this.personalities = data.personalities || [];
this.backgrounds = data.backgrounds || [];
} else {
this.notifyError('캐릭터 정보를 불러올 수 없습니다.');
}
} catch (e) {
console.error('캐릭터 정보 로드 오류:', e);
this.notifyError('캐릭터 정보를 불러오는데 실패했습니다.');
} finally {
this.isLoading = false;
}
},
async saveCharacter() {
// 등록(create) 모드에서만 필수값 유효성 검사를 강제
if (!this.isEdit && !this.isFormValid) {
this.notifyError("필수 항목을 모두 입력하세요");
return;
}
if (this.isLoading) return;
this.isLoading = true;
try {
// 태그, 메모리, 인물관계, 취미, 가치관, 목표, 성격 특성, 세계관 데이터 설정
this.character.tags = this.tags.filter(tag => tag && typeof tag === 'string' && tag.trim());
this.character.memories = [...this.memories];
this.character.relationships = [...this.relationships];
this.character.hobbies = [...this.hobbies];
this.character.values = [...this.values];
this.character.goals = [...this.goals];
this.character.personalities = [...this.personalities];
this.character.backgrounds = [...this.backgrounds];
let response;
if (this.isEdit) {
// 수정 시 변경된 필드만 전송
const changedData = this.getChangedFields();
response = await updateCharacter(changedData, this.character.image);
} else {
// 신규 등록 시 ID 필드를 제외한 데이터 전송
const characterWithoutId = { ...this.character };
delete characterWithoutId.id;
response = await createCharacter(characterWithoutId);
}
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess(this.isEdit ? '캐릭터가 수정되었습니다.' : '캐릭터가 등록되었습니다.');
this.goBack();
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
}
} catch (e) {
console.error('캐릭터 저장 오류:', e);
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
} finally {
this.isLoading = false;
}
}
}
}
</script>
<style scoped>
.v-card {
border-radius: 8px;
}
.memory-container {
max-height: 300px;
overflow-y: auto;
}
.memory-list {
max-height: 280px;
overflow-y: auto;
}
.memory-item {
background-color: #f5f5f5;
margin-bottom: 4px;
border-radius: 4px;
}
.memory-text {
white-space: pre-wrap;
word-break: break-word;
}
.delete-btn {
font-size: 12px;
}
.custom-caption {
font-size: 16px !important;
}
.required-asterisk >>> .v-label::after {
content: ' *';
color: #ff5252;
}
</style>