feat(character): 캐릭터 등록/수정 폼 개선

- 생년월일을 나이로 변경 (숫자만 입력 가능)
- 태그 기능 개선 (50자 이내, 20개 제한, 띄어쓰기 불가, 스페이스바로 등록)
- 인물관계 추가 (최대 200자, 최대 10개)
- 취미 목록 추가 (최대 100자, 최대 10개)
- 가치관 목록 추가 (최대 100자, 최대 10개)
- 목표 추가 (최대 200자, 최대 10개)
- 말투/특징적 표현 필드 추가 (최대 500자)
- 대화 스타일 추가 (최대 200자)
- 외모 설명 추가 (최대 1000자)
- 연령제한 제거
- UI 순서 변경 (채팅형태 입력 UI를 아래로 이동)
- 채팅형태 UI 개선 (최근 입력이 위쪽에 표시)
This commit is contained in:
Yu Sung 2025-08-07 17:45:48 +09:00
parent 13c85bb2a8
commit 72b1627f3f
1 changed files with 485 additions and 68 deletions

View File

@ -99,35 +99,21 @@
/>
</v-col>
<!-- 생년월일 -->
<!-- 나이 -->
<v-col
cols="12"
md="6"
>
<v-menu
v-model="birthDateMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
min-width="auto"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="formattedBirthDate"
label="생년월일"
readonly
v-model="character.age"
label="나이"
type="number"
min="0"
outlined
dense
v-bind="attrs"
v-on="on"
:rules="ageRules"
@input="validateNumberInput"
/>
</template>
<v-date-picker
v-model="character.birthDate"
locale="ko"
@input="birthDateMenu = false"
/>
</v-menu>
</v-col>
</v-row>
@ -145,20 +131,6 @@
dense
/>
</v-col>
<!-- 연령제한 - 스위치 형태 -->
<v-col
cols="12"
md="6"
>
<v-switch
v-model="character.ageRestricted"
label="연령제한"
color="primary"
hide-details
class="mt-4"
/>
</v-col>
</v-row>
<!-- 태그 - 입력 띄어쓰기 때마다 Chip 형태로 변경 -->
@ -173,6 +145,7 @@
deletable-chips
outlined
dense
:rules="tagRules"
@keydown.space.prevent="addTag"
>
<template v-slot:selection="{ attrs, item, select, selected }">
@ -190,7 +163,7 @@
<div
class="caption grey--text text--darken-1 custom-caption"
>
태그를 입력하고 엔터누르면 추가됩니다.
태그를 입력하고 엔터 또는 스페이스바누르면 추가됩니다. (50 이내, 최대 20, 띄어쓰기 불가)
</div>
</v-col>
</v-row>
@ -218,19 +191,6 @@
</v-col>
</v-row>
<!-- 인물 관계 -->
<v-row>
<v-col cols="12">
<v-textarea
v-model="character.relationships"
label="인물 관계"
outlined
auto-grow
rows="4"
/>
</v-col>
</v-row>
<!-- 성격 특성 -->
<v-row>
<v-col cols="12">
@ -249,10 +209,42 @@
<v-col cols="12">
<v-textarea
v-model="character.speechPattern"
label="말투/특징적 표현"
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.conversationStyle"
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>
@ -275,6 +267,314 @@
</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="newRelationship"
label="새 인물 관계 추가 (최대 200자)"
outlined
dense
counter="200"
:rules="[v => v.length <= 200 || '최대 200자까지 입력 가능합니다']"
@keyup.enter="addRelationship"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newRelationship.trim() || relationships.length >= 10"
@click="addRelationship"
>
추가
</v-btn>
</v-col>
</v-row>
</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">
{{ relationship }}
</v-list-item-title>
</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-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">
@ -402,10 +702,17 @@ export default {
isFormValid: false,
isEdit: false,
previewImage: null,
birthDateMenu: false,
newMemory: '',
newRelationship: '',
newHobby: '',
newValue: '',
newGoal: '',
tags: [],
memories: [],
relationships: [],
hobbies: [],
values: [],
goals: [],
character: {
id: null,
name: '',
@ -415,17 +722,25 @@ export default {
isActive: true,
createdAt: '',
gender: '',
birthDate: null,
age: '',
mbti: '',
ageRestricted: false,
worldView: '',
relationships: '',
personality: '',
speechPattern: '',
conversationStyle: '',
appearance: '',
systemPrompt: '',
tags: [],
memories: []
},
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) || '이름을 입력하세요'
@ -448,10 +763,6 @@ export default {
},
computed: {
formattedBirthDate() {
if (!this.character.birthDate) return '';
return this.character.birthDate;
}
},
watch: {
@ -487,6 +798,13 @@ export default {
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;
@ -500,10 +818,21 @@ export default {
addTag() {
const lastTag = this.tags[this.tags.length - 1];
if (lastTag && typeof lastTag === 'string' && lastTag.trim()) {
this.tags.splice(this.tags.length - 1, 1, 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개까지 등록 가능합니다.');
}
}
},
@ -513,7 +842,7 @@ export default {
addMemory() {
if (this.newMemory.trim()) {
this.memories.push(this.newMemory.trim());
this.memories.unshift(this.newMemory.trim());
this.newMemory = '';
}
},
@ -522,6 +851,86 @@ export default {
this.memories.splice(index, 1);
},
addRelationship() {
if (this.newRelationship.trim()) {
if (this.relationships.length >= 10) {
this.notifyError('인물 관계는 최대 10개까지 등록 가능합니다.');
return;
}
if (this.newRelationship.length > 200) {
this.newRelationship = this.newRelationship.substring(0, 200);
}
this.relationships.unshift(this.newRelationship.trim());
this.newRelationship = '';
}
},
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);
},
async loadCharacter(id) {
this.isLoading = true;
try {
@ -538,9 +947,13 @@ export default {
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 || [];
} else {
this.notifyError('캐릭터 정보를 불러올 수 없습니다.');
}
@ -563,9 +976,13 @@ export default {
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];
let response;