From 89b2f1f740f55a26a9b1ae4da358d5b0db600a0a Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 5 Aug 2025 12:15:06 +0900 Subject: [PATCH 01/33] =?UTF-8?q?fix:=20isLoading=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=90=9C=20=EB=B3=80=EC=88=98=20is=5Floading?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Can/CanCharge.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/Can/CanCharge.vue b/src/views/Can/CanCharge.vue index 49ce3df..c46bc0e 100644 --- a/src/views/Can/CanCharge.vue +++ b/src/views/Can/CanCharge.vue @@ -60,7 +60,7 @@ 지급할 캔 수: {{ can }} 캔 - + Date: Tue, 5 Aug 2025 14:46:27 +0900 Subject: [PATCH 02/33] =?UTF-8?q?feat:=20.kiro/,=20.junie/=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EC=97=90=20=EB=93=A4=EC=96=B4=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=80=20git=EC=97=90=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d5b69c2..b6d3e56 100644 --- a/.gitignore +++ b/.gitignore @@ -218,4 +218,7 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +.kiro/ +.junie/ + # End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows -- 2.40.1 From dbc46482b110afb662715164682a2bf29927ab1b Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 5 Aug 2025 15:00:59 +0900 Subject: [PATCH 03/33] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=B3=B8=20UI=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Chat/CharacterList.vue | 514 +++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 src/views/Chat/CharacterList.vue diff --git a/src/views/Chat/CharacterList.vue b/src/views/Chat/CharacterList.vue new file mode 100644 index 0000000..ec5b4ad --- /dev/null +++ b/src/views/Chat/CharacterList.vue @@ -0,0 +1,514 @@ + + + + + -- 2.40.1 From 439cc21e573a09f1dbfa7b9be290c46ffb86fc93 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 5 Aug 2025 15:14:24 +0900 Subject: [PATCH 04/33] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20-=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=B1=97=EB=B4=87=20=EB=A9=94=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/character.js | 16 ++++++++++++++++ src/components/SideMenu.vue | 18 ++++++++++++++++++ src/router/index.js | 11 +++++++++++ 3 files changed, 45 insertions(+) create mode 100644 src/api/character.js diff --git a/src/api/character.js b/src/api/character.js new file mode 100644 index 0000000..1fc22b9 --- /dev/null +++ b/src/api/character.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; + +// 캐릭터 리스트 +async function getCharacterList() { + return Vue.axios.get('/api/admin/characters') +} + +// 캐릭터 등록 + +// 캐릭터 수정 + +// 캐릭터 삭제 + +export { + getCharacterList +} diff --git a/src/components/SideMenu.vue b/src/components/SideMenu.vue index 8fa647a..26c4fc4 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -95,6 +95,24 @@ export default { let res = await api.getMenus(); if (res.status === 200 && res.data.success === true && res.data.data.length > 0) { this.items = res.data.data + + // 캐릭터 챗봇 메뉴 추가 + this.items.push({ + title: '캐릭터 챗봇', + route: null, + items: [ + { + title: '배너 등록', + route: '/character/banner', + items: null + }, + { + title: '캐릭터 리스트', + route: '/character', + items: null + } + ] + }) } else { this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") this.logout(); diff --git a/src/router/index.js b/src/router/index.js index 4b2f7fa..1f1270c 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -255,6 +255,17 @@ const routes = [ name: 'MarketingAdStatisticsView', component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue') }, + { + path: '/character', + name: 'CharacterList', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue') + }, + // TODO: CharacterBanner 컴포넌트가 구현되면 아래 라우트 주석 해제 + // { + // path: '/character/banner', + // name: 'CharacterBanner', + // component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue') + // }, ] }, { -- 2.40.1 From 94a989ea57eb17d4cac62a281129078111a7a683 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 5 Aug 2025 15:59:58 +0900 Subject: [PATCH 05/33] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=8F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/character.js | 102 ++++- src/router/index.js | 5 + src/views/Chat/CharacterForm.vue | 620 +++++++++++++++++++++++++++++++ src/views/Chat/CharacterList.vue | 275 +++----------- 4 files changed, 766 insertions(+), 236 deletions(-) create mode 100644 src/views/Chat/CharacterForm.vue diff --git a/src/api/character.js b/src/api/character.js index 1fc22b9..0333ddc 100644 --- a/src/api/character.js +++ b/src/api/character.js @@ -1,16 +1,112 @@ import Vue from 'vue'; // 캐릭터 리스트 -async function getCharacterList() { - return Vue.axios.get('/api/admin/characters') +async function getCharacterList(page = 1, size = 10) { + return Vue.axios.get('/api/admin/characters', { + params: { page, size } + }) +} + +// 캐릭터 검색 +async function searchCharacters(keyword, page = 1, size = 10) { + return Vue.axios.get('/api/admin/characters/search', { + params: { keyword, page, size } + }) +} + +// 캐릭터 상세 조회 +async function getCharacter(id) { + return Vue.axios.get(`/api/admin/characters/${id}`) } // 캐릭터 등록 +async function createCharacter(characterData) { + const formData = new FormData() + + // 기본 필드 추가 + formData.append('name', characterData.name) + formData.append('description', characterData.description) + formData.append('isActive', characterData.isActive) + + // 추가 필드가 있는 경우 추가 + if (characterData.personality) formData.append('personality', characterData.personality) + if (characterData.gender) formData.append('gender', characterData.gender) + if (characterData.birthDate) formData.append('birthDate', characterData.birthDate) + if (characterData.mbti) formData.append('mbti', characterData.mbti) + if (characterData.ageRestricted !== undefined) formData.append('ageRestricted', characterData.ageRestricted) + if (characterData.worldView) formData.append('worldView', characterData.worldView) + if (characterData.relationships) formData.append('relationships', characterData.relationships) + if (characterData.speechPattern) formData.append('speechPattern', characterData.speechPattern) + if (characterData.systemPrompt) formData.append('systemPrompt', characterData.systemPrompt) + + // 태그와 메모리는 배열이므로 JSON 문자열로 변환 + if (characterData.tags && characterData.tags.length > 0) { + formData.append('tags', JSON.stringify(characterData.tags)) + } + + if (characterData.memories && characterData.memories.length > 0) { + formData.append('memories', JSON.stringify(characterData.memories)) + } + + // 이미지가 있는 경우 추가 + if (characterData.image) formData.append('image', characterData.image) + + return Vue.axios.post('/api/admin/characters', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} // 캐릭터 수정 +async function updateCharacter(characterData) { + const formData = new FormData() + + // 기본 필드 추가 + formData.append('name', characterData.name) + formData.append('description', characterData.description) + formData.append('isActive', characterData.isActive) + + // 추가 필드가 있는 경우 추가 + if (characterData.personality) formData.append('personality', characterData.personality) + if (characterData.gender) formData.append('gender', characterData.gender) + if (characterData.birthDate) formData.append('birthDate', characterData.birthDate) + if (characterData.mbti) formData.append('mbti', characterData.mbti) + if (characterData.ageRestricted !== undefined) formData.append('ageRestricted', characterData.ageRestricted) + if (characterData.worldView) formData.append('worldView', characterData.worldView) + if (characterData.relationships) formData.append('relationships', characterData.relationships) + if (characterData.speechPattern) formData.append('speechPattern', characterData.speechPattern) + if (characterData.systemPrompt) formData.append('systemPrompt', characterData.systemPrompt) + + // 태그와 메모리는 배열이므로 JSON 문자열로 변환 + if (characterData.tags && characterData.tags.length > 0) { + formData.append('tags', JSON.stringify(characterData.tags)) + } + + if (characterData.memories && characterData.memories.length > 0) { + formData.append('memories', JSON.stringify(characterData.memories)) + } + + // 이미지가 있는 경우 추가 + if (characterData.image) formData.append('image', characterData.image) + + return Vue.axios.put(`/api/admin/characters/${characterData.id}`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} // 캐릭터 삭제 +async function deleteCharacter(id) { + return Vue.axios.delete(`/api/admin/characters/${id}`) +} export { - getCharacterList + getCharacterList, + searchCharacters, + getCharacter, + createCharacter, + updateCharacter, + deleteCharacter } diff --git a/src/router/index.js b/src/router/index.js index 1f1270c..05ac4f3 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -260,6 +260,11 @@ const routes = [ name: 'CharacterList', component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue') }, + { + path: '/character/form', + name: 'CharacterForm', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue') + }, // TODO: CharacterBanner 컴포넌트가 구현되면 아래 라우트 주석 해제 // { // path: '/character/banner', diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue new file mode 100644 index 0000000..b383725 --- /dev/null +++ b/src/views/Chat/CharacterForm.vue @@ -0,0 +1,620 @@ + + + + + diff --git a/src/views/Chat/CharacterList.vue b/src/views/Chat/CharacterList.vue index ec5b4ad..e8f3c9b 100644 --- a/src/views/Chat/CharacterList.vue +++ b/src/views/Chat/CharacterList.vue @@ -139,77 +139,6 @@ - - - - - {{ is_edit ? '캐릭터 수정' : '캐릭터 추가' }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 취소 - - - 저장 - - - - import VueShowMoreText from 'vue-show-more-text' +import { getCharacterList, searchCharacters, deleteCharacter as apiDeleteCharacter } from '@/api/character' export default { name: "CharacterList", @@ -255,21 +185,10 @@ export default { data() { return { is_loading: false, - show_dialog: false, show_delete_confirm_dialog: false, - is_edit: false, page: 1, total_page: 0, search_word: '', - character: { - id: null, - name: '', - description: '', - image: null, - imageUrl: '', - isActive: true, - createdAt: '' - }, characters: [], selected_character: {} } @@ -289,47 +208,18 @@ export default { }, showAddDialog() { - this.is_edit = false - this.character = { - id: null, - name: '', - description: '', - image: null, - imageUrl: '', - isActive: true, - createdAt: '' - } - this.show_dialog = true + // 페이지로 이동 + this.$router.push('/character/form'); }, showEditDialog(item) { - this.is_edit = true - this.selected_character = item - this.character = { - id: item.id, - name: item.name, - description: item.description, - image: null, - imageUrl: item.imageUrl, - isActive: item.isActive, - createdAt: item.createdAt - } - this.show_dialog = true + // 페이지로 이동하면서 id 전달 + this.$router.push({ + path: '/character/form', + query: { id: item.id } + }); }, - closeDialog() { - this.show_dialog = false - this.character = { - id: null, - name: '', - description: '', - image: null, - imageUrl: '', - isActive: true, - createdAt: '' - } - this.selected_character = {} - }, deleteConfirm(item) { this.selected_character = item @@ -341,68 +231,21 @@ export default { this.selected_character = {} }, - async saveCharacter() { - if ( - this.character.name === null || - this.character.name === undefined || - this.character.name.trim().length <= 0 - ) { - this.notifyError("이름을 입력하세요") - return - } - - if ( - this.character.description === null || - this.character.description === undefined || - this.character.description.trim().length <= 0 - ) { - this.notifyError("설명을 입력하세요") - return - } - - if (this.is_loading) return; - - this.is_loading = true - - try { - // 여기에 API 호출 코드를 추가합니다. - // 예시: - // const res = this.is_edit - // ? await api.updateCharacter(this.character) - // : await api.createCharacter(this.character); - - // API 호출이 없으므로 임시로 성공 처리 - setTimeout(() => { - this.closeDialog() - this.notifySuccess(this.is_edit ? '수정되었습니다.' : '추가되었습니다.') - this.getCharacters() - this.is_loading = false - }, 1000) - } catch (e) { - this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') - this.is_loading = false - } - }, async deleteCharacter() { if (this.is_loading) return; this.is_loading = true try { - // 여기에 API 호출 코드를 추가합니다. - // 예시: - // const res = await api.deleteCharacter(this.selected_character.id); - - // API 호출이 없으므로 임시로 성공 처리 - setTimeout(() => { - this.closeDeleteDialog() - this.notifySuccess('삭제되었습니다.') - this.getCharacters() - this.is_loading = false - }, 1000) + await apiDeleteCharacter(this.selected_character.id); + this.closeDeleteDialog(); + this.notifySuccess('삭제되었습니다.'); + await this.getCharacters(); } catch (e) { - this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') - this.is_loading = false + console.error('캐릭터 삭제 오류:', e); + this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); + } finally { + this.is_loading = false; } }, @@ -418,38 +261,22 @@ export default { async getCharacters() { this.is_loading = true try { - // 여기에 API 호출 코드를 추가합니다. - // 예시: - // const res = await api.getCharacters(this.page); + const response = await getCharacterList(this.page); - // API 호출이 없으므로 임시 데이터 생성 - setTimeout(() => { - // 임시 데이터 - const mockData = { - totalCount: 15, - items: Array.from({ length: 10 }, (_, i) => ({ - id: i + 1 + (this.page - 1) * 10, - name: `캐릭터 ${i + 1 + (this.page - 1) * 10}`, - description: `이것은 캐릭터 ${i + 1 + (this.page - 1) * 10}에 대한 설명입니다. 이 캐릭터는 다양한 특성을 가지고 있습니다.`, - imageUrl: 'https://via.placeholder.com/150', - isActive: Math.random() > 0.3, - createdAt: new Date().toISOString().split('T')[0] - })) - } + if (response && response.data) { + const data = response.data; + this.characters = data.items || []; - const total_page = Math.ceil(mockData.totalCount / 10) - this.characters = mockData.items - - if (total_page <= 0) - this.total_page = 1 - else - this.total_page = total_page - - this.is_loading = false - }, 500) + const total_page = Math.ceil((data.totalCount || 0) / 10); + this.total_page = total_page <= 0 ? 1 : total_page; + } else { + throw new Error('응답 데이터가 없습니다.'); + } } catch (e) { - this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') - this.is_loading = false + console.error('캐릭터 목록 조회 오류:', e); + this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); + } finally { + this.is_loading = false; } }, @@ -466,40 +293,22 @@ export default { } else { this.is_loading = true try { - // 여기에 API 호출 코드를 추가합니다. - // 예시: - // const res = await api.searchCharacters(this.search_word, this.page); + const response = await searchCharacters(this.search_word, this.page); - // API 호출이 없으므로 임시 데이터 생성 - setTimeout(() => { - // 검색 결과 임시 데이터 - const filteredItems = Array.from({ length: 3 }, (_, i) => ({ - id: i + 1, - name: `${this.search_word} 캐릭터 ${i + 1}`, - description: `이것은 ${this.search_word} 캐릭터 ${i + 1}에 대한 설명입니다.`, - imageUrl: 'https://via.placeholder.com/150', - isActive: true, - createdAt: new Date().toISOString().split('T')[0] - })) + if (response && response.data) { + const data = response.data; + this.characters = data.items || []; - const mockData = { - totalCount: 3, - items: filteredItems - } - - const total_page = Math.ceil(mockData.totalCount / 10) - this.characters = mockData.items - - if (total_page <= 0) - this.total_page = 1 - else - this.total_page = total_page - - this.is_loading = false - }, 500) + const total_page = Math.ceil((data.totalCount || 0) / 10); + this.total_page = total_page <= 0 ? 1 : total_page; + } else { + throw new Error('응답 데이터가 없습니다.'); + } } catch (e) { - this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.') - this.is_loading = false + console.error('캐릭터 검색 오류:', e); + this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); + } finally { + this.is_loading = false; } } } -- 2.40.1 From 49cd5a795b2827e36a0aa3d8562043022262f244 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 5 Aug 2025 16:56:25 +0900 Subject: [PATCH 06/33] =?UTF-8?q?fix:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=8F=BC=20-=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8,=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=BA=A1=EC=85=98=20=ED=81=AC=EA=B8=B0=2016px?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Chat/CharacterForm.vue | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index b383725..829b53e 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -187,8 +187,10 @@ -
- 태그를 입력하고 스페이스바를 누르면 추가됩니다. +
+ 태그를 입력하고 엔터를 누르면 추가됩니다.
@@ -265,7 +267,9 @@ auto-grow rows="4" /> -
+
캐릭터의 행동 방식과 제약사항을 정의하는 시스템 프롬프트입니다.
@@ -617,4 +621,8 @@ export default { .delete-btn { font-size: 12px; } + +.custom-caption { + font-size: 16px !important; +} -- 2.40.1 From 3783714c756fb794d811b82f0456928dab563174 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 7 Aug 2025 16:36:45 +0900 Subject: [PATCH 07/33] =?UTF-8?q?feat(ui):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 헤더 변경 (이름→캐릭터명, 설명→캐릭터 설명 등) - 이미지 크기 100x100으로 설정 - 캐릭터 설명, 말투, 대화 스타일을 보기 버튼으로 표시 - 다이얼로그를 통해 상세 내용 표시 기능 추가 --- src/views/Chat/CharacterList.vue | 147 +++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 19 deletions(-) diff --git a/src/views/Chat/CharacterList.vue b/src/views/Chat/CharacterList.vue index e8f3c9b..ab1a9c8 100644 --- a/src/views/Chat/CharacterList.vue +++ b/src/views/Chat/CharacterList.vue @@ -50,16 +50,31 @@ 이미지 - 이름 + 캐릭터명 - 설명 + 성별 - 상태 + 나이 - 생성일 + 캐릭터 설명 + + + MBTI + + + 말투 + + + 대화 스타일 + + + 등록일 + + + 수정일 관리 @@ -74,29 +89,45 @@ {{ item.id }} {{ item.name }} - - + {{ item.gender || '-' }} + {{ calculateAge(item.birthDate) }} + + + 보기 + + + {{ item.mbti || '-' }} + + + 보기 + - - {{ item.isActive ? '활성' : '비활성' }} - + 보기 + {{ item.createdAt }} + {{ item.updatedAt || '-' }} @@ -170,22 +201,49 @@ + + + + + + {{ detail_title }} + + + +
{{ detail_content }}
+
+ + + + 닫기 + + +
+
+ + -- 2.40.1 From efca5e445d0e19033eb9f73725b26d29a15427d6 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 8 Aug 2025 22:07:15 +0900 Subject: [PATCH 18/33] =?UTF-8?q?feat(character-banner):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=B0=B0=EB=84=88=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐릭터 검색 결과가 없으면 '검색결과가 없습니다.'라고 안내 --- src/views/Chat/CharacterBanner.vue | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/views/Chat/CharacterBanner.vue b/src/views/Chat/CharacterBanner.vue index 530d4ab..5ea4bb6 100644 --- a/src/views/Chat/CharacterBanner.vue +++ b/src/views/Chat/CharacterBanner.vue @@ -174,6 +174,16 @@ + + + + 검색결과가 없습니다. + + + Date: Mon, 11 Aug 2025 15:51:45 +0900 Subject: [PATCH 19/33] =?UTF-8?q?feat(chat):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=ED=8F=BC=EC=97=90=20'=ED=95=9C=20=EC=A4=84=20=EC=86=8C?= =?UTF-8?q?=EA=B0=9C',=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9C=A0=ED=98=95,?= =?UTF-8?q?=20=EC=9B=90=EC=9E=91=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20API=20=ED=95=84=EB=93=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CharacterForm.vue: 설명을 한 줄 소개(TextField)로 변경하고 MBTI 옆에 캐릭터 유형 Select 추가, 태그 아래 원작명/원작링크 필드 추가. api/character.js: createCharacter 요청에 characterType, originalTitle, originalLink 반영. 수정/등록 로직에 관련 필드 매핑 및 변경 필드 추출 반영. 왜: 신규 요구사항 반영 및 API/데이터 정합성 확보. --- src/api/character.js | 3 ++ src/views/Chat/CharacterForm.vue | 64 +++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/api/character.js b/src/api/character.js index 2c1f32c..b090cd9 100644 --- a/src/api/character.js +++ b/src/api/character.js @@ -34,6 +34,9 @@ async function createCharacter(characterData) { age: characterData.age, gender: characterData.gender, mbti: characterData.mbti, + characterType: characterData.type, + originalTitle: characterData.originalTitle, + originalLink: characterData.originalLink, speechPattern: characterData.speechPattern, speechStyle: characterData.conversationStyle, appearance: characterData.appearance, diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index 8286c0a..9c638aa 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -69,17 +69,16 @@ - + - @@ -117,7 +116,7 @@ - + + + + @@ -168,6 +179,32 @@ + + + + + + + + + + @@ -925,6 +962,9 @@ export default { gender: '', age: '', mbti: '', + type: '', + originalTitle: '', + originalLink: '', speechPattern: '', conversationStyle: '', appearance: '', @@ -950,8 +990,8 @@ export default { v => (v && v.trim().length > 0) || '이름을 입력하세요' ], descriptionRules: [ - v => !!v || '설명을 입력하세요', - v => (v && v.trim().length > 0) || '설명을 입력하세요' + v => !!v || '한 줄 소개를 입력하세요', + v => (v && v.trim().length > 0) || '한 줄 소개를 입력하세요' ], imageRules: [ v => !this.isEdit || !!v || !!this.character.imageUrl || '이미지를 선택하세요' @@ -962,7 +1002,8 @@ export default { 'INFJ', 'INFP', 'ENFJ', 'ENFP', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP' - ] + ], + typeOptions: ['Clone', 'Character'] } }, @@ -1247,6 +1288,9 @@ export default { age: this.character.age, gender: this.character.gender, mbti: this.character.mbti, + type: this.character.type, + originalTitle: this.character.originalTitle, + originalLink: this.character.originalLink, speechPattern: this.character.speechPattern, speechStyle: this.character.conversationStyle, appearance: this.character.appearance, @@ -1269,7 +1313,7 @@ export default { // 기본 필드 비교 const simpleFields = [ - 'name', 'description', 'age', 'gender', 'mbti', + 'name', 'description', 'age', 'gender', 'mbti', 'type', 'originalTitle', 'originalLink', 'speechPattern', 'isActive' ]; -- 2.40.1 From ba248f7680b74c804383a272d296550644e1cce4 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 12 Aug 2025 21:09:08 +0900 Subject: [PATCH 20/33] =?UTF-8?q?feat(chat):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8,=20=EC=B6=94=EA=B0=80/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=8F=BC,=20=EB=B0=B0=EB=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - response의 데이터 구조에 맞춰서 코드 수정 --- src/api/character.js | 45 ++++++++++++++------- src/views/Chat/CharacterBanner.vue | 50 ++++++++++++++++------- src/views/Chat/CharacterForm.vue | 65 +++++++++++++++++++----------- src/views/Chat/CharacterList.vue | 20 +++++---- 4 files changed, 119 insertions(+), 61 deletions(-) diff --git a/src/api/character.js b/src/api/character.js index b090cd9..e4f9eb1 100644 --- a/src/api/character.js +++ b/src/api/character.js @@ -19,6 +19,14 @@ async function getCharacter(id) { return Vue.axios.get(`/admin/chat/character/${id}`) } +// 내부 헬퍼: 빈 문자열을 null로 변환 +function toNullIfBlank(value) { + if (typeof value === 'string') { + return value.trim() === '' ? null : value; + } + return value === '' ? null : value; +} + // 캐릭터 등록 async function createCharacter(characterData) { const formData = new FormData() @@ -28,18 +36,18 @@ async function createCharacter(characterData) { // 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가 const requestData = { - name: characterData.name, - systemPrompt: characterData.systemPrompt, - description: characterData.description, - age: characterData.age, - gender: characterData.gender, - mbti: characterData.mbti, - characterType: characterData.type, - originalTitle: characterData.originalTitle, - originalLink: characterData.originalLink, - speechPattern: characterData.speechPattern, - speechStyle: characterData.conversationStyle, - appearance: characterData.appearance, + name: toNullIfBlank(characterData.name), + systemPrompt: toNullIfBlank(characterData.systemPrompt), + description: toNullIfBlank(characterData.description), + age: toNullIfBlank(characterData.age), + gender: toNullIfBlank(characterData.gender), + mbti: toNullIfBlank(characterData.mbti), + characterType: toNullIfBlank(characterData.type), + originalTitle: toNullIfBlank(characterData.originalTitle), + originalLink: toNullIfBlank(characterData.originalLink), + speechPattern: toNullIfBlank(characterData.speechPattern), + speechStyle: toNullIfBlank(characterData.speechStyle), + appearance: toNullIfBlank(characterData.appearance), tags: characterData.tags || [], hobbies: characterData.hobbies || [], values: characterData.values || [], @@ -66,9 +74,18 @@ async function updateCharacter(characterData, image = null) { // 이미지가 있는 경우에만 FormData에 추가 if (image) formData.append('image', image) - // 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 + // 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환) // characterData는 이미 변경된 필드만 포함하고 있음 - formData.append('request', JSON.stringify(characterData)) + const processed = {} + Object.keys(characterData).forEach(key => { + const value = characterData[key] + if (typeof value === 'string' || value === '') { + processed[key] = toNullIfBlank(value) + } else { + processed[key] = value + } + }) + formData.append('request', JSON.stringify(processed)) return Vue.axios.put(`/admin/chat/character/update`, formData, { headers: { diff --git a/src/views/Chat/CharacterBanner.vue b/src/views/Chat/CharacterBanner.vue index 5ea4bb6..5858852 100644 --- a/src/views/Chat/CharacterBanner.vue +++ b/src/views/Chat/CharacterBanner.vue @@ -197,7 +197,9 @@ -
선택된 캐릭터: {{ selectedCharacter.name }}
+
+ 선택된 캐릭터: {{ selectedCharacter.name }} +
@@ -356,8 +358,9 @@ export default { try { const response = await getCharacterBannerList(this.page); - if (response && response.data) { - const newBanners = response.data.content || []; + if (response && response.status === 200 && response.data && response.data.success === true) { + const data = response.data.data; + const newBanners = data.content || []; this.banners = [...this.banners, ...newBanners]; // 더 불러올 데이터가 있는지 확인 @@ -458,8 +461,9 @@ export default { try { const response = await searchCharacters(this.searchKeyword); - if (response && response.data) { - this.searchResults = response.data.content || []; + if (response && response.status === 200 && response.data && response.data.success === true) { + const data = response.data.data; + this.searchResults = data.content || []; this.searchPerformed = true; } } catch (error) { @@ -482,19 +486,27 @@ export default { try { if (this.isEdit) { // 배너 수정 - await updateCharacterBanner({ + const response = await updateCharacterBanner({ image: this.bannerForm.image, characterId: this.selectedCharacter.id, bannerId: this.bannerForm.bannerId }); - this.notifySuccess('배너가 수정되었습니다.'); + if (response && response.status === 200 && response.data && response.data.success === true) { + this.notifySuccess('배너가 수정되었습니다.'); + } else { + this.notifyError('배너 수정을 실패했습니다.'); + } } else { // 배너 추가 - await createCharacterBanner({ + const response = await createCharacterBanner({ image: this.bannerForm.image, characterId: this.selectedCharacter.id }); - this.notifySuccess('배너가 추가되었습니다.'); + if (response && response.status === 200 && response.data && response.data.success === true) { + this.notifySuccess('배너가 추가되었습니다.'); + } else { + this.notifyError('배너 추가를 실패했습니다.'); + } } // 다이얼로그 닫고 배너 목록 새로고침 @@ -514,10 +526,14 @@ export default { this.isSubmitting = true; try { - await deleteCharacterBanner(this.selectedBanner.id); - this.notifySuccess('배너가 삭제되었습니다.'); - this.showDeleteDialog = false; - this.refreshBanners(); + const response = await deleteCharacterBanner(this.selectedBanner.id); + if (response && response.status === 200 && response.data && response.data.success === true) { + this.notifySuccess('배너가 삭제되었습니다.'); + this.showDeleteDialog = false; + this.refreshBanners(); + } else { + this.notifyError('배너 삭제에 실패했습니다.'); + } } catch (error) { console.error('배너 삭제 오류:', error); this.notifyError('배너 삭제에 실패했습니다.'); @@ -538,8 +554,12 @@ export default { // 드래그 앤 드롭으로 순서 변경 후 API 호출 try { const bannerIds = this.banners.map(banner => banner.id); - await updateCharacterBannerOrder(bannerIds); - this.notifySuccess('배너 순서가 변경되었습니다.'); + const response = await updateCharacterBannerOrder(bannerIds); + if (response && response.status === 200 && response.data && response.data.success === true) { + this.notifySuccess('배너 순서가 변경되었습니다.'); + } else { + this.notifyError('배너 순서 변경에 실패했습니다.'); + } } catch (error) { console.error('배너 순서 변경 오류:', error); this.notifyError('배너 순서 변경에 실패했습니다.'); diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index 9c638aa..c8af1b1 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -135,7 +135,7 @@ md="6" > { + if (result[f] == null) result[f] = ''; + }); + return result; + }, + // 변경된 필드만 추출하는 함수 getChangedFields() { if (!this.originalCharacter || !this.isEdit) { @@ -1288,11 +1309,11 @@ export default { age: this.character.age, gender: this.character.gender, mbti: this.character.mbti, - type: this.character.type, + characterType: this.character.characterType, originalTitle: this.character.originalTitle, originalLink: this.character.originalLink, speechPattern: this.character.speechPattern, - speechStyle: this.character.conversationStyle, + speechStyle: this.character.speechStyle, appearance: this.character.appearance, tags: this.character.tags || [], hobbies: this.character.hobbies || [], @@ -1313,26 +1334,21 @@ export default { // 기본 필드 비교 const simpleFields = [ - 'name', 'description', 'age', 'gender', 'mbti', 'type', 'originalTitle', 'originalLink', - 'speechPattern', 'isActive' + 'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalTitle', 'originalLink', + 'speechPattern', 'speechStyle', 'isActive' ]; simpleFields.forEach(field => { - if (this.character[field] !== this.originalCharacter[field]) { + if (!this.areEqualConsideringBlankNull(this.character[field], this.originalCharacter[field])) { changedFields[field] = this.character[field]; } }); - // 특수 필드 매핑 처리 (conversationStyle은 API에서 speechStyle로 사용됨) - if (this.character.conversationStyle !== this.originalCharacter.conversationStyle) { - changedFields.speechStyle = this.character.conversationStyle; - } - - if (this.character.systemPrompt !== this.originalCharacter.systemPrompt) { + if (!this.areEqualConsideringBlankNull(this.character.systemPrompt, this.originalCharacter.systemPrompt)) { changedFields.systemPrompt = this.character.systemPrompt; } - if (this.character.appearance !== this.originalCharacter.appearance) { + if (!this.areEqualConsideringBlankNull(this.character.appearance, this.originalCharacter.appearance)) { changedFields.appearance = this.character.appearance; } @@ -1377,16 +1393,17 @@ export default { const response = await getCharacter(id); // API 응답에서 캐릭터 정보 설정 - if (response && response.success === true && response.data) { - const data = response.data; + 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, // 기본 구조 유지 - ...data, // API 응답 데이터로 덮어쓰기 + ...normalized, // API 응답 데이터(정규화)로 덮어쓰기 image: null // 파일 입력은 초기화 }; @@ -1444,11 +1461,11 @@ export default { response = await createCharacter(characterWithoutId); } - if (response && response.success === true && response.data) { - this.notifySuccess(this.isEdit ? '캐릭터가 수정되었습니다.' : '캐릭터가 등록되었습니다.'); - this.goBack(); + if (response && response.status === 200 && response.data && response.data.success === true) { + this.notifySuccess(this.isEdit ? '캐릭터가 수정되었습니다.' : '캐릭터가 등록되었습니다.'); + this.goBack(); } else { - this.notifyError('응답 데이터가 없습니다.'); + this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); } } catch (e) { console.error('캐릭터 저장 오류:', e); diff --git a/src/views/Chat/CharacterList.vue b/src/views/Chat/CharacterList.vue index 043db06..93136eb 100644 --- a/src/views/Chat/CharacterList.vue +++ b/src/views/Chat/CharacterList.vue @@ -380,14 +380,18 @@ export default { try { const response = await getCharacterList(this.page); - if (response && response.data) { - const data = response.data; - this.characters = data.content || []; + if (response && response.status === 200) { + if (response.data.success === true) { + const data = response.data.data; + this.characters = data.content || []; - const total_page = Math.ceil((data.totalCount || 0) / 20); - this.total_page = total_page <= 0 ? 1 : total_page; + const total_page = Math.ceil((data.totalCount || 0) / 20); + this.total_page = total_page <= 0 ? 1 : total_page; + } else { + this.notifyError('응답 데이터가 없습니다.'); + } } else { - this.notifyError('응답 데이터가 없습니다.'); + this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.'); } } catch (e) { console.error('캐릭터 목록 조회 오류:', e); @@ -412,8 +416,8 @@ export default { try { const response = await searchCharacters(this.search_word, this.page); - if (response && response.data) { - const data = response.data; + if (response && response.status === 200 && response.data && response.data.success === true) { + const data = response.data.data; this.characters = data.content || []; const total_page = Math.ceil((data.totalCount || 0) / 20); -- 2.40.1 From 38161af543d9bff76a156e71fc7a38775d76c8e7 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 12 Aug 2025 21:53:20 +0900 Subject: [PATCH 21/33] =?UTF-8?q?feat(chat):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색창 제거 --- src/views/Chat/CharacterList.vue | 59 ++------------------------------ 1 file changed, 2 insertions(+), 57 deletions(-) diff --git a/src/views/Chat/CharacterList.vue b/src/views/Chat/CharacterList.vue index 93136eb..181c924 100644 --- a/src/views/Chat/CharacterList.vue +++ b/src/views/Chat/CharacterList.vue @@ -19,23 +19,6 @@ 캐릭터 추가 - - - - - 검색 - - - @@ -251,7 +234,7 @@ -- 2.40.1 From 8f502f6d4d32977aa26b2bd3a46d320294ef015b Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 12 Aug 2025 22:19:46 +0900 Subject: [PATCH 22/33] =?UTF-8?q?fix(chat):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95=20=ED=8F=BC=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수정 모드 이미지 변경 강제 제거, 시스템 프롬프트 필수 규칙 추가, 저장 버튼 라벨 조건부 표기(저장/수정) - 수정 모드: 변경사항 또는 새 이미지 선택 시에만 저장 활성화, 등록 모드: 유효성만 충족 시 저장 가능 - 왜: 수정 UX 개선 및 필수 입력 요건 충족 --- src/views/Chat/CharacterForm.vue | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index c8af1b1..e2dd4b1 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -270,6 +270,7 @@ outlined auto-grow rows="4" + :rules="systemPromptRules" />
- 저장 + {{ isEdit ? '수정' : '저장' }} @@ -994,7 +995,7 @@ export default { v => (v && v.trim().length > 0) || '한 줄 소개를 입력하세요' ], imageRules: [ - v => !this.isEdit || !!v || !!this.character.imageUrl || '이미지를 선택하세요' + v => (this.isEdit ? true : (!!v || '이미지를 선택하세요')) ], genderOptions: ['남성', '여성', '기타'], mbtiOptions: [ @@ -1003,11 +1004,25 @@ export default { 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP' ], - typeOptions: ['Clone', 'Character'] + typeOptions: ['Clone', 'Character'], + systemPromptRules: [ + v => (this.isEdit ? true : (!!v && v.trim().length > 0) || '시스템 프롬프트를 입력하세요') + ] } }, computed: { + isSaveDisabled() { + if (this.isLoading) return true; + if (!this.isFormValid) return true; + if (!this.isEdit) return false; // 등록 시에는 변경 감지 없이 유효성만 확인 + + // 수정 시에는 변경 사항이 있는 경우에만 저장 가능 + const changed = this.getChangedFields(); + const hasNonIdField = Object.keys(changed || {}).some(k => k !== 'id'); + const imageChanged = !!this.character.image; // 새 이미지 선택 여부 + return !(hasNonIdField || imageChanged); + } }, watch: { -- 2.40.1 From 231539fd27d8c22f03072aa5bc9a7d7e1b2b31cc Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 12 Aug 2025 23:12:17 +0900 Subject: [PATCH 23/33] =?UTF-8?q?feat(=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=B0=B0=EB=84=88):=20=EB=93=B1=EB=A1=9D=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=A7=8C=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=8B=AB=EA=B3=A0=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Chat/CharacterBanner.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/Chat/CharacterBanner.vue b/src/views/Chat/CharacterBanner.vue index 5858852..5b6f93c 100644 --- a/src/views/Chat/CharacterBanner.vue +++ b/src/views/Chat/CharacterBanner.vue @@ -366,9 +366,10 @@ export default { // 더 불러올 데이터가 있는지 확인 this.hasMoreItems = newBanners.length > 0; this.page++; + } else { + this.notifyError('배너 목록을 불러오는데 실패했습니다.'); } } catch (error) { - console.error('배너 목록 로드 오류:', error); this.notifyError('배너 목록을 불러오는데 실패했습니다.'); } finally { this.isLoading = false; @@ -504,14 +505,13 @@ export default { }); if (response && response.status === 200 && response.data && response.data.success === true) { this.notifySuccess('배너가 추가되었습니다.'); + // 다이얼로그 닫고 배너 목록 새로고침 + this.closeDialog(); + this.refreshBanners(); } else { this.notifyError('배너 추가를 실패했습니다.'); } } - - // 다이얼로그 닫고 배너 목록 새로고침 - this.closeDialog(); - this.refreshBanners(); } catch (error) { console.error('배너 저장 오류:', error); this.notifyError('배너 저장에 실패했습니다.'); -- 2.40.1 From 30e08c862a0f47c295a24511e0f85c17f841b334 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 12 Aug 2025 23:31:36 +0900 Subject: [PATCH 24/33] =?UTF-8?q?fix(side-menu):=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=ED=99=9C=EC=84=B1=ED=99=94=EB=90=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95\n\n-=20/character=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EB=A9=94=EB=89=B4=EC=97=90=20exact=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EC=A0=81=EC=9A=A9(:exact)\n-=20/character?= =?UTF-8?q?/banner=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=20/character=EA=B0=80?= =?UTF-8?q?=20=ED=95=A8=EA=BB=98=20=EC=84=A0=ED=83=9D=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/SideMenu.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/SideMenu.vue b/src/components/SideMenu.vue index 26c4fc4..841a0c0 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -43,6 +43,7 @@ > {{ childItem.title }} -- 2.40.1 From e09f654abaed8feb053d9ee16c549f25903707b9 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 13 Aug 2025 00:55:35 +0900 Subject: [PATCH 25/33] =?UTF-8?q?fix(character-form):=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EB=A7=8C=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isSaveDisabled 로직을 등록/수정 모드로 분리 - 수정(edit) 모드에서는 필수값 유효성과 무관하게 변경 감지 시 버튼 활성화 - 등록(create) 모드에서는 기존대로 폼 유효성으로 활성화 판단 - saveCharacter에서도 등록 모드에서만 필수값 유효성 검사를 강제하도록 수정 관련 파일: src/views/Chat/CharacterForm.vue --- src/views/Chat/CharacterForm.vue | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index e2dd4b1..a8e6df1 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -1014,10 +1014,12 @@ export default { computed: { isSaveDisabled() { if (this.isLoading) return true; - if (!this.isFormValid) return true; - if (!this.isEdit) return false; // 등록 시에는 변경 감지 없이 유효성만 확인 + // 등록(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; // 새 이미지 선택 여부 @@ -1443,7 +1445,8 @@ export default { }, async saveCharacter() { - if (!this.isFormValid) { + // 등록(create) 모드에서만 필수값 유효성 검사를 강제 + if (!this.isEdit && !this.isFormValid) { this.notifyError("필수 항목을 모두 입력하세요"); return; } -- 2.40.1 From 806af4aba00b93dd161dea658209e66e870c9dcb Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Wed, 13 Aug 2025 17:30:42 +0900 Subject: [PATCH 26/33] =?UTF-8?q?fix(character-form):=20=EC=9D=B8=EB=AC=BC?= =?UTF-8?q?=20=EA=B4=80=EA=B3=84=20=EC=9E=85=EB=A0=A5=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=203=EB=8B=A8=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=85=EB=A0=A5=20=EB=B0=A9=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relationshipType, currentStatus를 v-text-field로 변경하고 길이 제한(<=10자) 및 필수 입력 검증 추가 - 인물 관계 입력을 3단 레이아웃으로 재구성 (1행: 상대방 이름+관계명, 2행: 관계 타입+현재 상태+중요도, 3행: 관계 설명) - addRelationship 로직 보강: 각 필드 substring 보정, 중요도 1~10 범위 보정, 최대 개수(10개) 체크 - 저장 로직 비교 함수에서 relationships를 객체 배열 비교 대상에 포함하여 변경 감지 정확도 개선 왜: 기존 TextField 하나로 관계를 모두 입력해 가독성과 구조화가 어려웠고, 선택형 필드 요구사항이 변경되어 직접 입력하도록 수정 필요 무엇: UI/검증/데이터 처리 전반을 요구사항에 맞게 분리 및 보강 --- src/views/Chat/CharacterForm.vue | 321 ++++++++++++++++++++++--------- 1 file changed, 230 insertions(+), 91 deletions(-) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index a8e6df1..ef3d470 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -280,82 +280,6 @@ - - - - -

- 인물 관계 -

- - - - - - - - - - 추가 - - - - - - - - - - - - {{ relationship }} - - - - - 삭제 - - - - - - 인물 관계가 없습니다. 위 입력창에서 인물 관계를 추가해주세요. (최대 10개) - - -
-
@@ -881,6 +805,154 @@ + + + + +

+ 인물 관계 +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 추가 + + + + + + + + + + {{ relationship.personName }} - {{ relationship.relationshipName }} + + + 타입: {{ relationship.relationshipType }} | 상태: {{ relationship.currentStatus }} | 중요도: {{ relationship.importance }} + + + {{ relationship.description }} + + + + + 삭제 + + + + + + 인물 관계가 없습니다. 위 입력창에서 인물 관계를 추가해주세요. (최대 10개) + + +
+
+ @@ -936,7 +1008,14 @@ export default { newMemoryTitle: '', newMemoryContent: '', newMemoryEmotion: '', - newRelationship: '', + newRelationship: { + personName: '', + relationshipName: '', + description: '', + importance: null, + relationshipType: '', + currentStatus: '' + }, newHobby: '', newValue: '', newGoal: '', @@ -1007,6 +1086,33 @@ export default { 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자까지 입력 가능합니다' ] } }, @@ -1145,19 +1251,52 @@ export default { }, 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 = ''; + 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) { @@ -1371,7 +1510,7 @@ export default { // 배열 필드 비교 (깊은 비교) const arrayFields = [ - 'tags', 'hobbies', 'values', 'goals', 'relationships' + 'tags', 'hobbies', 'values', 'goals' ]; arrayFields.forEach(field => { @@ -1387,7 +1526,7 @@ export default { // 복잡한 객체 배열 비교 const objectArrayFields = [ - 'personalities', 'backgrounds', 'memories' + 'personalities', 'backgrounds', 'memories', 'relationships' ]; objectArrayFields.forEach(field => { -- 2.40.1 From 071502d869a95101fd9aad86d6f62e6dd55ef029 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 15 Aug 2025 01:37:46 +0900 Subject: [PATCH 27/33] =?UTF-8?q?fix(character-form):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EB=B9=84=ED=99=9C=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=95=84=EC=88=98?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8=20*=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록(create) 모드에서 필수값 충족 시 버튼 활성화되도록 유효성 처리 정비 - 필수 항목 라벨에 빨간색 * 표시 - 인물관계 입력 필드의 검증 규칙을 v-form 유효성에서 제외 - 인물관계 필드 힌트 문구 개선 --- src/views/Chat/CharacterForm.vue | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index ef3d470..f15511c 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -34,6 +34,7 @@ truncate-length="15" outlined dense + :class="{ 'required-asterisk': !isEdit }" :rules="imageRules" /> @@ -62,6 +63,7 @@ v-model="character.name" label="캐릭터명" :rules="nameRules" + class="required-asterisk" required outlined dense @@ -76,6 +78,7 @@ v-model="character.description" label="캐릭터 한 줄 소개" :rules="descriptionRules" + class="required-asterisk" required outlined dense @@ -110,6 +113,7 @@ min="0" outlined dense + class="required-asterisk" :rules="ageRules" @input="validateNumberInput" /> @@ -270,6 +274,7 @@ outlined auto-grow rows="4" + :class="{ 'required-asterisk': !isEdit }" :rules="systemPromptRules" />
@@ -834,7 +838,6 @@ outlined dense counter="20" - :rules="relationshipNameRules" /> @@ -844,21 +847,19 @@ @@ -870,7 +871,6 @@ type="number" min="1" max="10" - :rules="relationshipImportanceRules" /> @@ -1668,4 +1668,9 @@ export default { .custom-caption { font-size: 16px !important; } + +.required-asterisk >>> .v-label::after { + content: ' *'; + color: #ff5252; +} -- 2.40.1 From 63ebe9708f4b9e38dd5d8732734ba36e0fd1a725 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 22 Aug 2025 02:25:37 +0900 Subject: [PATCH 28/33] =?UTF-8?q?feat(character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC(=EB=AA=A9=EB=A1=9D/=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C/=EC=A0=95=EB=A0=AC)=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/character.js | 54 ++++- src/router/index.js | 10 + src/views/Chat/CharacterImageForm.vue | 306 ++++++++++++++++++++++++ src/views/Chat/CharacterImageList.vue | 325 ++++++++++++++++++++++++++ src/views/Chat/CharacterList.vue | 17 ++ 5 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 src/views/Chat/CharacterImageForm.vue create mode 100644 src/views/Chat/CharacterImageList.vue diff --git a/src/api/character.js b/src/api/character.js index e4f9eb1..5e70f90 100644 --- a/src/api/character.js +++ b/src/api/character.js @@ -154,6 +154,52 @@ async function updateCharacterBannerOrder(bannerIds) { return Vue.axios.put('/admin/chat/banner/orders', {ids: bannerIds}) } +// 캐릭터 이미지 리스트 +async function getCharacterImageList(characterId, page = 1, size = 20) { + return Vue.axios.get('/admin/chat/character/image/list', { + params: { characterId, page: page - 1, size } + }) +} + +// 캐릭터 이미지 상세 +async function getCharacterImage(imageId) { + return Vue.axios.get(`/admin/chat/character/image/${imageId}`) +} + +// 캐릭터 이미지 등록 +async function createCharacterImage(imageData) { + const formData = new FormData() + if (imageData.image) formData.append('image', imageData.image) + const requestData = { + characterId: imageData.characterId, + imagePriceCan: imageData.imagePriceCan, + messagePriceCan: imageData.messagePriceCan, + isAdult: imageData.isAdult, + triggers: imageData.triggers || [] + } + formData.append('request', JSON.stringify(requestData)) + return Vue.axios.post('/admin/chat/character/image/register', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +// 캐릭터 이미지 수정 (트리거만 수정) +async function updateCharacterImage(imageData) { + const imageId = imageData.imageId + const payload = { triggers: imageData.triggers || [] } + return Vue.axios.put(`/admin/chat/character/image/${imageId}/triggers`, payload) +} + +// 캐릭터 이미지 삭제 +async function deleteCharacterImage(imageId) { + return Vue.axios.delete(`/admin/chat/character/image/${imageId}`) +} + +// 캐릭터 이미지 순서 변경 +async function updateCharacterImageOrder(characterId, imageIds) { + return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: imageIds }) +} + export { getCharacterList, searchCharacters, @@ -164,5 +210,11 @@ export { createCharacterBanner, updateCharacterBanner, deleteCharacterBanner, - updateCharacterBannerOrder + updateCharacterBannerOrder, + getCharacterImageList, + getCharacterImage, + createCharacterImage, + updateCharacterImage, + deleteCharacterImage, + updateCharacterImageOrder } diff --git a/src/router/index.js b/src/router/index.js index 7888f8c..5afc84a 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -270,6 +270,16 @@ const routes = [ name: 'CharacterBanner', component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue') }, + { + path: '/character/images', + name: 'CharacterImageList', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageList.vue') + }, + { + path: '/character/images/form', + name: 'CharacterImageForm', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue') + }, ] }, { diff --git a/src/views/Chat/CharacterImageForm.vue b/src/views/Chat/CharacterImageForm.vue new file mode 100644 index 0000000..7359bcc --- /dev/null +++ b/src/views/Chat/CharacterImageForm.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/src/views/Chat/CharacterImageList.vue b/src/views/Chat/CharacterImageList.vue new file mode 100644 index 0000000..e579afe --- /dev/null +++ b/src/views/Chat/CharacterImageList.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/src/views/Chat/CharacterList.vue b/src/views/Chat/CharacterList.vue index 181c924..31f872d 100644 --- a/src/views/Chat/CharacterList.vue +++ b/src/views/Chat/CharacterList.vue @@ -141,6 +141,16 @@ 수정 + + + 이미지 + + Date: Thu, 28 Aug 2025 15:20:14 +0900 Subject: [PATCH 29/33] =?UTF-8?q?=EB=A7=90=ED=88=AC/=ED=8A=B9=EC=A7=95?= =?UTF-8?q?=EC=A0=81=20=ED=91=9C=ED=98=84=201000=EC=9E=90,=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EC=8A=A4=ED=83=80=EC=9D=BC=201000=EC=9E=90?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Chat/CharacterForm.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index f15511c..191f10b 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -225,12 +225,12 @@ @@ -240,12 +240,12 @@ -- 2.40.1 From b94aa543651f410843c93487fb36379e76c88899 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 28 Aug 2025 19:38:21 +0900 Subject: [PATCH 30/33] =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=B1=97?= =?UTF-8?q?=EB=B4=87=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/character.js | 59 ++- src/components/SideMenu.vue | 5 + src/router/index.js | 10 + src/views/Chat/CharacterCuration.vue | 341 ++++++++++++++++ src/views/Chat/CharacterCurationDetail.vue | 429 +++++++++++++++++++++ 5 files changed, 843 insertions(+), 1 deletion(-) create mode 100644 src/views/Chat/CharacterCuration.vue create mode 100644 src/views/Chat/CharacterCurationDetail.vue diff --git a/src/api/character.js b/src/api/character.js index 5e70f90..62b7829 100644 --- a/src/api/character.js +++ b/src/api/character.js @@ -200,6 +200,53 @@ async function updateCharacterImageOrder(characterId, imageIds) { return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: imageIds }) } +// 캐릭터 큐레이션 목록 +async function getCharacterCurationList() { + return Vue.axios.get('/admin/chat/character/curation/list') +} + +// 캐릭터 큐레이션 등록 +async function createCharacterCuration({ title, isAdult, isActive }) { + return Vue.axios.post('/admin/chat/character/curation/register', { title, isAdult, isActive }) +} + +// 캐릭터 큐레이션 수정 +// payload: { id: Long, title?, isAdult?, isActive? } +async function updateCharacterCuration(payload) { + return Vue.axios.put('/admin/chat/character/curation/update', payload) +} + +// 캐릭터 큐레이션 삭제 +async function deleteCharacterCuration(curationId) { + return Vue.axios.delete(`/admin/chat/character/curation/${curationId}`) +} + +// 캐릭터 큐레이션 정렬 순서 변경 +async function updateCharacterCurationOrder(ids) { + return Vue.axios.put('/admin/chat/character/curation/reorder', { ids }) +} + +// 큐레이션에 캐릭터 등록 (다중 등록) +// characterIds: Array +async function addCharacterToCuration(curationId, characterIds) { + return Vue.axios.post(`/admin/chat/character/curation/${curationId}/characters`, { characterIds }) +} + +// 큐레이션에서 캐릭터 삭제 +async function removeCharacterFromCuration(curationId, characterId) { + return Vue.axios.delete(`/admin/chat/character/curation/${curationId}/characters/${characterId}`) +} + +// 큐레이션 내 캐릭터 정렬 순서 변경 +async function updateCurationCharactersOrder(curationId, characterIds) { + return Vue.axios.put(`/admin/chat/character/curation/${curationId}/characters/reorder`, { characterIds }) +} + +// 큐레이션 캐릭터 목록 조회 (가정된 엔드포인트) +async function getCharactersInCuration(curationId) { + return Vue.axios.get(`/admin/chat/character/curation/${curationId}/characters`) +} + export { getCharacterList, searchCharacters, @@ -216,5 +263,15 @@ export { createCharacterImage, updateCharacterImage, deleteCharacterImage, - updateCharacterImageOrder + updateCharacterImageOrder, + // Character Curation + getCharacterCurationList, + createCharacterCuration, + updateCharacterCuration, + deleteCharacterCuration, + updateCharacterCurationOrder, + addCharacterToCuration, + removeCharacterFromCuration, + updateCurationCharactersOrder, + getCharactersInCuration } diff --git a/src/components/SideMenu.vue b/src/components/SideMenu.vue index 841a0c0..16a3ade 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -111,6 +111,11 @@ export default { title: '캐릭터 리스트', route: '/character', items: null + }, + { + title: '큐레이션', + route: '/character/curation', + items: null } ] }) diff --git a/src/router/index.js b/src/router/index.js index 5afc84a..5cbf3a9 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -280,6 +280,16 @@ const routes = [ name: 'CharacterImageForm', component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue') }, + { + path: '/character/curation', + name: 'CharacterCuration', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCuration.vue') + }, + { + path: '/character/curation/detail', + name: 'CharacterCurationDetail', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue') + }, ] }, { diff --git a/src/views/Chat/CharacterCuration.vue b/src/views/Chat/CharacterCuration.vue new file mode 100644 index 0000000..d5e3240 --- /dev/null +++ b/src/views/Chat/CharacterCuration.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/views/Chat/CharacterCurationDetail.vue b/src/views/Chat/CharacterCurationDetail.vue new file mode 100644 index 0000000..e010038 --- /dev/null +++ b/src/views/Chat/CharacterCurationDetail.vue @@ -0,0 +1,429 @@ + + + + + -- 2.40.1 From bc8833483a956e46f387a128c7c628916a47cb0a Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 2 Sep 2025 15:31:24 +0900 Subject: [PATCH 31/33] =?UTF-8?q?fix(character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D/=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 트리거 단어 최소 개수 3개로 수정 --- src/views/Chat/CharacterImageForm.vue | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/views/Chat/CharacterImageForm.vue b/src/views/Chat/CharacterImageForm.vue index 7359bcc..dbf3594 100644 --- a/src/views/Chat/CharacterImageForm.vue +++ b/src/views/Chat/CharacterImageForm.vue @@ -126,7 +126,7 @@
- 트리거를 입력하고 엔터를 누르면 추가됩니다. (20자 이내, 최소 5개, 최대 10개) + 트리거를 입력하고 엔터를 누르면 추가됩니다. (20자 이내, 최소 3개, 최대 10개)
@@ -179,7 +179,7 @@ export default { previewImage: null, triggers: [], triggerRules: [ - v => (v && v.length >= 5 && v.length <= 10) || '트리거는 최소 5개, 최대 10개까지 등록 가능합니다' + v => (v && v.length >= 3 && v.length <= 10) || '트리거는 최소 3개, 최대 10개까지 등록 가능합니다' ], imageRules: [ v => !!v || '이미지를 선택하세요' @@ -188,7 +188,7 @@ export default { }, computed: { canSubmit() { - const triggersValid = this.triggers && this.triggers.length >= 5 && this.triggers.length <= 10 + const triggersValid = this.triggers && this.triggers.length >= 3 && this.triggers.length <= 10 if (this.isEdit) return triggersValid return !!this.form.image && triggersValid } @@ -260,9 +260,9 @@ export default { }, async save() { if (this.isSubmitting) return - // 트리거 개수 검증: 최소 5개, 최대 10개 - if (!this.triggers || this.triggers.length < 5 || this.triggers.length > 10) { - this.notifyError('트리거는 최소 5개, 최대 10개여야 합니다.') + // 트리거 개수 검증: 최소 3개, 최대 10개 + if (!this.triggers || this.triggers.length < 3 || this.triggers.length > 10) { + this.notifyError('트리거는 최소 3개, 최대 10개여야 합니다.') return } this.isSubmitting = true -- 2.40.1 From 199049ab7ce73e24927fb53df8269b749c0584f3 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sat, 6 Sep 2025 00:58:00 +0900 Subject: [PATCH 32/33] =?UTF-8?q?feat(chat):=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=ED=8F=BC=EC=97=90=20JSON=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0/=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 툴바에 'JSON 다운로드/업로드' 버튼 추가 - buildSerializablePayload, exportToJson, onImportFileChange, applyImportedData 메서드 구현 - 이미지(image, imageUrl) 및 isActive는 직렬화/역직렬화에서 제외 - 업로드 시 버전 검증 및 길이/개수 제한, 중요도(1~10) 보정 적용 - 사용자 알림 메시지(성공/오류) 한글화 --- src/views/Chat/CharacterForm.vue | 144 +++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index 191f10b..c1af94b 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -10,6 +10,9 @@ {{ isEdit ? '캐릭터 수정' : '캐릭터 등록' }} + JSON 다운로드 + + JSON 업로드 @@ -1630,6 +1633,147 @@ export default { } finally { this.isLoading = false; } + }, + + // ===== JSON 내보내기/가져오기 ===== + buildSerializablePayload() { + const c = { + ...this.character, + tags: this.tags.filter(t => t && typeof t === 'string' && t.trim()), + hobbies: [...this.hobbies], + values: [...this.values], + goals: [...this.goals], + relationships: [...this.relationships], + personalities: [...this.personalities], + backgrounds: [...this.backgrounds], + memories: [...this.memories] + }; + + // 이미지 및 상태 관련 필드 제외 + delete c.image; + delete c.imageUrl; + delete c.isActive; + + return { + version: 1, + ...c + }; + }, + + exportToJson() { + try { + const payload = this.buildSerializablePayload(); + const json = JSON.stringify(payload, null, 2); + const blob = new Blob([json], { type: 'application/json;charset=utf-8' }); + + const filenameBase = (this.character.name || 'character').replace(/\s+/g, '_'); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${filenameBase}.character.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + this.notifySuccess('JSON 파일로 내보냈습니다.'); + } catch (e) { + console.error(e); + this.notifyError('내보내기 중 오류가 발생했습니다.'); + } + }, + + onImportFileChange(e) { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const text = reader.result; + const data = JSON.parse(text); + this.applyImportedData(data); + this.notifySuccess('JSON 데이터를 불러왔습니다.'); + } catch (err) { + console.error(err); + this.notifyError('유효한 JSON 파일이 아닙니다.'); + } finally { + // 동일 파일 재업로드 허용 + e.target.value = ''; + } + }; + reader.readAsText(file, 'utf-8'); + }, + + applyImportedData(data) { + // 1) 버전/형식 검증 + if (!data || (data.version !== 1 && data.version !== undefined)) { + throw new Error('지원되지 않는 파일 버전입니다.'); + } + + // 2) 안전한 추출(helper) + const arr = (v) => Array.isArray(v) ? v : []; + const str = (v) => (v == null ? '' : String(v)); + + // 3) 이미지 및 상태 관련 필드 강제 제거 (파일 포맷 요구사항) + if (data) { + delete data.image; + delete data.imageUrl; + delete data.isActive; + } + + // 4) 기본 필드 주입 + const patch = { + name: str(data.name), + description: str(data.description), + systemPrompt: str(data.systemPrompt), + age: str(data.age), + gender: str(data.gender), + mbti: str(data.mbti), + characterType: str(data.characterType), + originalTitle: str(data.originalTitle), + originalLink: str(data.originalLink), + speechPattern: str(data.speechPattern), + speechStyle: str(data.speechStyle), + appearance: str(data.appearance) + }; + + // 5) 배열 필드 주입 + 간단 검증/정제 + const sanitizeString = (s, max) => String(s || '').trim().substring(0, max); + + this.tags = arr(data.tags).map(t => sanitizeString(t, 50)).slice(0, 20); + this.hobbies = arr(data.hobbies).map(h => sanitizeString(h, 100)).slice(0, 10); + this.values = arr(data.values).map(v => sanitizeString(v, 100)).slice(0, 10); + this.goals = arr(data.goals).map(g => sanitizeString(g, 200)).slice(0, 10); + + this.memories = arr(data.memories).map(m => ({ + title: sanitizeString(m.title, 100), + content: sanitizeString(m.content, 1000), + emotion: sanitizeString(m.emotion, 50) + })).slice(0, 20); + + this.relationships = arr(data.relationships).map(r => ({ + personName: sanitizeString(r.personName, 10), + relationshipName: sanitizeString(r.relationshipName, 20), + description: sanitizeString(r.description, 500), + importance: Math.max(1, Math.min(10, parseInt(r.importance || 1))), + relationshipType: sanitizeString(r.relationshipType, 10), + currentStatus: sanitizeString(r.currentStatus, 10) + })).slice(0, 10); + + this.personalities = arr(data.personalities).map(p => ({ + trait: sanitizeString(p.trait, 100), + description: sanitizeString(p.description, 500) + })).slice(0, 10); + + this.backgrounds = arr(data.backgrounds).map(b => ({ + topic: sanitizeString(b.topic, 100), + description: sanitizeString(b.description, 1000) + })).slice(0, 10); + + // 6) character에 반영 (image는 반드시 null 유지) + this.character = { + ...this.character, + ...patch, + image: null + }; } } } -- 2.40.1 From 5ee0fe6a6069a5fc7152e44e053cc16ae55399ae Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 9 Sep 2025 14:54:18 +0900 Subject: [PATCH 33/33] =?UTF-8?q?fix(chat):=20=EC=9D=B8=EB=AC=BC=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=82=AD=EC=A0=9C=20=ED=9B=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수정 모드에서 saveCharacter가 변경 필드만 전송하면서 relationships 배열이 제외되어 삭제/수정 사항이 서버에 반영되지 않는 문제가 있었습니다. 수정 시 항상 relationships를 포함해 서버와 동기화되도록 변경했습니다. - CharacterForm.vue: update 시 changedData.relationships 항상 포함 --- src/views/Chat/CharacterForm.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue index c1af94b..42abbd0 100644 --- a/src/views/Chat/CharacterForm.vue +++ b/src/views/Chat/CharacterForm.vue @@ -1613,6 +1613,8 @@ export default { if (this.isEdit) { // 수정 시 변경된 필드만 전송 const changedData = this.getChangedFields(); + // 인물관계는 삭제를 포함해 항상 서버와 동기화를 보장하기 위해 항상 포함 + changedData.relationships = this.character.relationships || []; response = await updateCharacter(changedData, this.character.image); } else { // 신규 등록 시 ID 필드를 제외한 데이터 전송 -- 2.40.1