1677 lines
55 KiB
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="말투/특징적 표현 (최대 1000자)"
|
|
outlined
|
|
auto-grow
|
|
rows="4"
|
|
counter="1000"
|
|
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- 대화 스타일 -->
|
|
<v-row>
|
|
<v-col cols="12">
|
|
<v-textarea
|
|
v-model="character.speechStyle"
|
|
label="대화 스타일 (최대 1000자)"
|
|
outlined
|
|
auto-grow
|
|
rows="4"
|
|
counter="1000"
|
|
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
|
|
/>
|
|
</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>
|