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 diff --git a/src/api/character.js b/src/api/character.js new file mode 100644 index 0000000..62b7829 --- /dev/null +++ b/src/api/character.js @@ -0,0 +1,277 @@ +import Vue from 'vue'; + +// 캐릭터 리스트 +async function getCharacterList(page = 1, size = 20) { + return Vue.axios.get('/admin/chat/character/list', { + params: { page: page - 1, size } + }) +} + +// 캐릭터 검색 +async function searchCharacters(searchTerm, page = 1, size = 20) { + return Vue.axios.get('/admin/chat/banner/search-character', { + params: { searchTerm, page: page - 1, size } + }) +} + +// 캐릭터 상세 조회 +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() + + // 이미지만 FormData에 추가 + if (characterData.image) formData.append('image', characterData.image) + + // 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가 + const requestData = { + 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 || [], + goals: characterData.goals || [], + relationships: characterData.relationships || [], + personalities: characterData.personalities || [], + backgrounds: characterData.backgrounds || [], + memories: characterData.memories || [] + } + + formData.append('request', JSON.stringify(requestData)) + + return Vue.axios.post('/admin/chat/character/register', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +// 캐릭터 수정 +async function updateCharacter(characterData, image = null) { + const formData = new FormData() + + // 이미지가 있는 경우에만 FormData에 추가 + if (image) formData.append('image', image) + + // 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환) + // 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: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +// 캐릭터 배너 리스트 조회 +async function getCharacterBannerList(page = 1, size = 20) { + return Vue.axios.get('/admin/chat/banner/list', { + params: { page: page - 1, size } + }) +} + +// 캐릭터 배너 등록 +async function createCharacterBanner(bannerData) { + const formData = new FormData() + + // 이미지 FormData에 추가 + if (bannerData.image) formData.append('image', bannerData.image) + + // 캐릭터 ID를 JSON 문자열로 변환하여 request 필드에 추가 + const requestData = { + characterId: bannerData.characterId + } + + formData.append('request', JSON.stringify(requestData)) + + return Vue.axios.post('/admin/chat/banner/register', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +// 캐릭터 배너 수정 +async function updateCharacterBanner(bannerData) { + const formData = new FormData() + + // 이미지가 있는 경우에만 FormData에 추가 + if (bannerData.image) formData.append('image', bannerData.image) + + // 캐릭터 ID와 배너 ID를 JSON 문자열로 변환하여 request 필드에 추가 + const requestData = { + characterId: bannerData.characterId, + bannerId: bannerData.bannerId + } + + formData.append('request', JSON.stringify(requestData)) + + return Vue.axios.put('/admin/chat/banner/update', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} + +// 캐릭터 배너 삭제 +async function deleteCharacterBanner(bannerId) { + return Vue.axios.delete(`/admin/chat/banner/${bannerId}`) +} + +// 캐릭터 배너 순서 변경 +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 }) +} + +// 캐릭터 큐레이션 목록 +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, + getCharacter, + createCharacter, + updateCharacter, + getCharacterBannerList, + createCharacterBanner, + updateCharacterBanner, + deleteCharacterBanner, + updateCharacterBannerOrder, + getCharacterImageList, + getCharacterImage, + createCharacterImage, + updateCharacterImage, + deleteCharacterImage, + 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 8fa647a..16a3ade 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -43,6 +43,7 @@ > {{ childItem.title }} @@ -95,6 +96,29 @@ 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 + }, + { + title: '큐레이션', + route: '/character/curation', + items: null + } + ] + }) } else { this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") this.logout(); diff --git a/src/router/index.js b/src/router/index.js index 4b2f7fa..5cbf3a9 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -255,6 +255,41 @@ const routes = [ name: 'MarketingAdStatisticsView', component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue') }, + { + path: '/character', + name: 'CharacterList', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue') + }, + { + path: '/character/form', + name: 'CharacterForm', + component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue') + }, + { + path: '/character/banner', + 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') + }, + { + 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/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 }} 캔 - + +
+ + + mdi-arrow-left + + + 캐릭터 배너 관리 + + + + + + + + 배너 추가 + + + + + + + + + + + + + + + + + + + + + + +

등록된 배너가 없습니다.

+
+
+ + + + + + + +
+ + + + + + {{ isEdit ? '배너 수정' : '배너 추가' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ character.name }} + + + + + + + + + 검색결과가 없습니다. + + + + + + + + + + + + + +
+ 선택된 캐릭터: {{ selectedCharacter.name }} +
+
+
+
+
+
+
+
+ + + + 취소 + + + 저장 + + +
+
+ + + + + + 배너 삭제 + + + 삭제 할까요? + + + + + 취소 + + + 삭제 + + + + +
+ + + + + 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 @@ + + + + + diff --git a/src/views/Chat/CharacterForm.vue b/src/views/Chat/CharacterForm.vue new file mode 100644 index 0000000..42abbd0 --- /dev/null +++ b/src/views/Chat/CharacterForm.vue @@ -0,0 +1,1822 @@ + + + + + diff --git a/src/views/Chat/CharacterImageForm.vue b/src/views/Chat/CharacterImageForm.vue new file mode 100644 index 0000000..dbf3594 --- /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 new file mode 100644 index 0000000..31f872d --- /dev/null +++ b/src/views/Chat/CharacterList.vue @@ -0,0 +1,406 @@ + + + + +