Compare commits
23 Commits
a3e82a81f8
...
test
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3c28367be9 | ||
![]() |
8f0958848d | ||
![]() |
a4cf43b88a | ||
![]() |
40c5a6593e | ||
![]() |
edab727c22 | ||
![]() |
00b12d0edb | ||
![]() |
6507b025de | ||
![]() |
cd86973b60 | ||
![]() |
1e4dcffc17 | ||
![]() |
5ee0fe6a60 | ||
![]() |
199049ab7c | ||
![]() |
bc8833483a | ||
![]() |
b94aa54365 | ||
![]() |
478ef2e7fe | ||
![]() |
63ebe9708f | ||
![]() |
071502d869 | ||
![]() |
806af4aba0 | ||
![]() |
e09f654aba | ||
![]() |
30e08c862a | ||
![]() |
231539fd27 | ||
![]() |
8f502f6d4d | ||
![]() |
38161af543 | ||
![]() |
ba248f7680 |
@@ -24,7 +24,7 @@ async function getCalculateCommunityPost(startDate, endDate, page, size) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getSettlementRatio(page) {
|
async function getSettlementRatio(page) {
|
||||||
return Vue.axios.get('/admin/calculate/ratio?page=' + (page - 1) + "&size=20'");
|
return Vue.axios.get('/admin/calculate/ratio?page=' + (page - 1) + "&size=20");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCreatorSettlementRatio(creatorSettlementRatio) {
|
async function createCreatorSettlementRatio(creatorSettlementRatio) {
|
||||||
@@ -57,6 +57,21 @@ async function getCalculateCommunityByCreator(startDate, endDate, page, size) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCreatorSettlementRatio(creatorSettlementRatio) {
|
||||||
|
const request = {
|
||||||
|
memberId: creatorSettlementRatio.creator_id,
|
||||||
|
subsidy: creatorSettlementRatio.subsidy,
|
||||||
|
liveSettlementRatio: creatorSettlementRatio.liveSettlementRatio,
|
||||||
|
contentSettlementRatio: creatorSettlementRatio.contentSettlementRatio,
|
||||||
|
communitySettlementRatio: creatorSettlementRatio.communitySettlementRatio
|
||||||
|
};
|
||||||
|
return Vue.axios.post('/admin/calculate/ratio/update', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCreatorSettlementRatio(memberId) {
|
||||||
|
return Vue.axios.post('/admin/calculate/ratio/delete/' + memberId);
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getCalculateLive,
|
getCalculateLive,
|
||||||
getCalculateContent,
|
getCalculateContent,
|
||||||
@@ -65,6 +80,8 @@ export {
|
|||||||
getCalculateCommunityPost,
|
getCalculateCommunityPost,
|
||||||
getSettlementRatio,
|
getSettlementRatio,
|
||||||
createCreatorSettlementRatio,
|
createCreatorSettlementRatio,
|
||||||
|
updateCreatorSettlementRatio,
|
||||||
|
deleteCreatorSettlementRatio,
|
||||||
getCalculateLiveByCreator,
|
getCalculateLiveByCreator,
|
||||||
getCalculateContentByCreator,
|
getCalculateContentByCreator,
|
||||||
getCalculateCommunityByCreator
|
getCalculateCommunityByCreator
|
||||||
|
@@ -7,18 +7,33 @@ async function getCharacterList(page = 1, size = 20) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 캐릭터 검색
|
// 캐릭터 검색 (배너용 기존 함수)
|
||||||
async function searchCharacters(searchTerm, page = 1, size = 20) {
|
async function searchCharacters(searchTerm, page = 1, size = 20) {
|
||||||
return Vue.axios.get('/admin/chat/banner/search-character', {
|
return Vue.axios.get('/admin/chat/banner/search-character', {
|
||||||
params: { searchTerm, page: page - 1, size }
|
params: { searchTerm, page: page - 1, size }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 캐릭터 리스트 검색 (요구사항: /admin/chat/character/search)
|
||||||
|
async function searchCharacterList(searchTerm, page = 1, size = 20) {
|
||||||
|
return Vue.axios.get('/admin/chat/character/search', {
|
||||||
|
params: { searchTerm, page: page - 1, size }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 캐릭터 상세 조회
|
// 캐릭터 상세 조회
|
||||||
async function getCharacter(id) {
|
async function getCharacter(id) {
|
||||||
return Vue.axios.get(`/admin/chat/character/${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) {
|
async function createCharacter(characterData) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@@ -28,18 +43,17 @@ async function createCharacter(characterData) {
|
|||||||
|
|
||||||
// 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가
|
// 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가
|
||||||
const requestData = {
|
const requestData = {
|
||||||
name: characterData.name,
|
name: toNullIfBlank(characterData.name),
|
||||||
systemPrompt: characterData.systemPrompt,
|
systemPrompt: toNullIfBlank(characterData.systemPrompt),
|
||||||
description: characterData.description,
|
description: toNullIfBlank(characterData.description),
|
||||||
age: characterData.age,
|
age: toNullIfBlank(characterData.age),
|
||||||
gender: characterData.gender,
|
gender: toNullIfBlank(characterData.gender),
|
||||||
mbti: characterData.mbti,
|
mbti: toNullIfBlank(characterData.mbti),
|
||||||
characterType: characterData.type,
|
characterType: toNullIfBlank(characterData.type),
|
||||||
originalTitle: characterData.originalTitle,
|
originalWorkId: characterData.originalWorkId || null,
|
||||||
originalLink: characterData.originalLink,
|
speechPattern: toNullIfBlank(characterData.speechPattern),
|
||||||
speechPattern: characterData.speechPattern,
|
speechStyle: toNullIfBlank(characterData.speechStyle),
|
||||||
speechStyle: characterData.conversationStyle,
|
appearance: toNullIfBlank(characterData.appearance),
|
||||||
appearance: characterData.appearance,
|
|
||||||
tags: characterData.tags || [],
|
tags: characterData.tags || [],
|
||||||
hobbies: characterData.hobbies || [],
|
hobbies: characterData.hobbies || [],
|
||||||
values: characterData.values || [],
|
values: characterData.values || [],
|
||||||
@@ -66,9 +80,18 @@ async function updateCharacter(characterData, image = null) {
|
|||||||
// 이미지가 있는 경우에만 FormData에 추가
|
// 이미지가 있는 경우에만 FormData에 추가
|
||||||
if (image) formData.append('image', image)
|
if (image) formData.append('image', image)
|
||||||
|
|
||||||
// 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가
|
// 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환)
|
||||||
// characterData는 이미 변경된 필드만 포함하고 있음
|
// 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, {
|
return Vue.axios.put(`/admin/chat/character/update`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -137,9 +160,111 @@ async function updateCharacterBannerOrder(bannerIds) {
|
|||||||
return Vue.axios.put('/admin/chat/banner/orders', {ids: 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<Long>
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐릭터별 정산 목록
|
||||||
|
// params: { startDateStr, endDateStr, sort, page, size }
|
||||||
|
async function getCharacterCalculateList({ startDateStr, endDateStr, sort = 'TOTAL_SALES_DESC', page = 0, size = 30 }) {
|
||||||
|
return Vue.axios.get('/admin/chat/calculate/characters', {
|
||||||
|
params: { startDateStr, endDateStr, sort, page, size }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getCharacterList,
|
getCharacterList,
|
||||||
searchCharacters,
|
searchCharacters,
|
||||||
|
searchCharacterList,
|
||||||
getCharacter,
|
getCharacter,
|
||||||
createCharacter,
|
createCharacter,
|
||||||
updateCharacter,
|
updateCharacter,
|
||||||
@@ -147,5 +272,22 @@ export {
|
|||||||
createCharacterBanner,
|
createCharacterBanner,
|
||||||
updateCharacterBanner,
|
updateCharacterBanner,
|
||||||
deleteCharacterBanner,
|
deleteCharacterBanner,
|
||||||
updateCharacterBannerOrder
|
updateCharacterBannerOrder,
|
||||||
|
getCharacterImageList,
|
||||||
|
getCharacterImage,
|
||||||
|
createCharacterImage,
|
||||||
|
updateCharacterImage,
|
||||||
|
deleteCharacterImage,
|
||||||
|
updateCharacterImageOrder,
|
||||||
|
// Character Curation
|
||||||
|
getCharacterCurationList,
|
||||||
|
createCharacterCuration,
|
||||||
|
updateCharacterCuration,
|
||||||
|
deleteCharacterCuration,
|
||||||
|
updateCharacterCurationOrder,
|
||||||
|
addCharacterToCuration,
|
||||||
|
removeCharacterFromCuration,
|
||||||
|
updateCurationCharactersOrder,
|
||||||
|
getCharactersInCuration,
|
||||||
|
getCharacterCalculateList
|
||||||
}
|
}
|
||||||
|
87
src/api/original.js
Normal file
87
src/api/original.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
|
||||||
|
// 공통: 값 그대로 전달 (빈 문자열 유지)
|
||||||
|
function toNullIfBlank(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim() === '' ? null : value;
|
||||||
|
}
|
||||||
|
return value === '' ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 리스트
|
||||||
|
export async function getOriginalList(page = 1, size = 20) {
|
||||||
|
return Vue.axios.get('/admin/chat/original/list', {
|
||||||
|
params: { page: page - 1, size }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 등록
|
||||||
|
export async function createOriginal(data) {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (data.image) formData.append('image', data.image);
|
||||||
|
const request = {
|
||||||
|
title: toNullIfBlank(data.title),
|
||||||
|
contentType: toNullIfBlank(data.contentType),
|
||||||
|
category: toNullIfBlank(data.category),
|
||||||
|
isAdult: !!data.isAdult,
|
||||||
|
description: toNullIfBlank(data.description),
|
||||||
|
originalLink: toNullIfBlank(data.originalLink), // 원천 원작 링크
|
||||||
|
originalWork: toNullIfBlank(data.originalWork),
|
||||||
|
writer: toNullIfBlank(data.writer),
|
||||||
|
studio: toNullIfBlank(data.studio),
|
||||||
|
originalLinks: Array.isArray(data.originalLinks) ? data.originalLinks : [],
|
||||||
|
tags: Array.isArray(data.tags) ? data.tags : []
|
||||||
|
};
|
||||||
|
formData.append('request', JSON.stringify(request));
|
||||||
|
return Vue.axios.post('/admin/chat/original/register', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 수정
|
||||||
|
export async function updateOriginal(data, image = null) {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (image) formData.append('image', image);
|
||||||
|
const processed = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
processed[key] = data[key];
|
||||||
|
})
|
||||||
|
formData.append('request', JSON.stringify(processed));
|
||||||
|
return Vue.axios.put('/admin/chat/original/update', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 삭제
|
||||||
|
export async function deleteOriginal(id) {
|
||||||
|
return Vue.axios.delete(`/admin/chat/original/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 상세
|
||||||
|
export async function getOriginal(id) {
|
||||||
|
return Vue.axios.get(`/admin/chat/original/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 속 캐릭터 조회
|
||||||
|
export async function getOriginalCharacters(id, page = 1, size = 20) {
|
||||||
|
return Vue.axios.get(`/admin/chat/original/${id}/characters`, {
|
||||||
|
params: { page: page - 1, size }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작 검색
|
||||||
|
export async function searchOriginals(searchTerm) {
|
||||||
|
return Vue.axios.get('/admin/chat/original/search', {
|
||||||
|
params: { searchTerm }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작에 캐릭터 연결
|
||||||
|
export async function assignCharactersToOriginal(id, characterIds = []) {
|
||||||
|
return Vue.axios.post(`/admin/chat/original/${id}/assign-characters`, { characterIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원작에서 캐릭터 연결 해제
|
||||||
|
export async function unassignCharactersFromOriginal(id, characterIds = []) {
|
||||||
|
return Vue.axios.post(`/admin/chat/original/${id}/unassign-characters`, { characterIds })
|
||||||
|
}
|
@@ -43,6 +43,7 @@
|
|||||||
>
|
>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:to="childItem.route"
|
:to="childItem.route"
|
||||||
|
:exact="childItem.route === '/character'"
|
||||||
active-class="blue white--text"
|
active-class="blue white--text"
|
||||||
>
|
>
|
||||||
<v-list-item-title>{{ childItem.title }}</v-list-item-title>
|
<v-list-item-title>{{ childItem.title }}</v-list-item-title>
|
||||||
@@ -110,7 +111,22 @@ export default {
|
|||||||
title: '캐릭터 리스트',
|
title: '캐릭터 리스트',
|
||||||
route: '/character',
|
route: '/character',
|
||||||
items: null
|
items: null
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
title: '큐레이션',
|
||||||
|
route: '/character/curation',
|
||||||
|
items: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '정산',
|
||||||
|
route: '/character/calculate',
|
||||||
|
items: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '원작',
|
||||||
|
route: '/original-work',
|
||||||
|
items: null
|
||||||
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
@@ -270,6 +270,46 @@ const routes = [
|
|||||||
name: 'CharacterBanner',
|
name: 'CharacterBanner',
|
||||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue')
|
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')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/character/calculate',
|
||||||
|
name: 'CharacterCalculate',
|
||||||
|
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCalculateList.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/original-work',
|
||||||
|
name: 'OriginalList',
|
||||||
|
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalList.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/original-work/form',
|
||||||
|
name: 'OriginalForm',
|
||||||
|
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalForm.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/original-work/detail',
|
||||||
|
name: 'OriginalDetail',
|
||||||
|
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalDetail.vue')
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@@ -60,7 +60,7 @@
|
|||||||
max-width="300"
|
max-width="300"
|
||||||
>
|
>
|
||||||
<v-img
|
<v-img
|
||||||
:src="banner.imageUrl"
|
:src="banner.imagePath"
|
||||||
height="200"
|
height="200"
|
||||||
contain
|
contain
|
||||||
/>
|
/>
|
||||||
@@ -197,7 +197,9 @@
|
|||||||
</v-avatar>
|
</v-avatar>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col>
|
<v-col>
|
||||||
<div class="font-weight-medium">선택된 캐릭터: {{ selectedCharacter.name }}</div>
|
<div class="font-weight-medium">
|
||||||
|
선택된 캐릭터: {{ selectedCharacter.name }}
|
||||||
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -356,16 +358,18 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const response = await getCharacterBannerList(this.page);
|
const response = await getCharacterBannerList(this.page);
|
||||||
|
|
||||||
if (response && response.data) {
|
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||||
const newBanners = response.data.content || [];
|
const data = response.data.data;
|
||||||
|
const newBanners = data.content || [];
|
||||||
this.banners = [...this.banners, ...newBanners];
|
this.banners = [...this.banners, ...newBanners];
|
||||||
|
|
||||||
// 더 불러올 데이터가 있는지 확인
|
// 더 불러올 데이터가 있는지 확인
|
||||||
this.hasMoreItems = newBanners.length > 0;
|
this.hasMoreItems = newBanners.length > 0;
|
||||||
this.page++;
|
this.page++;
|
||||||
|
} else {
|
||||||
|
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('배너 목록 로드 오류:', error);
|
|
||||||
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
|
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
@@ -458,8 +462,9 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const response = await searchCharacters(this.searchKeyword);
|
const response = await searchCharacters(this.searchKeyword);
|
||||||
|
|
||||||
if (response && response.data) {
|
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||||
this.searchResults = response.data.content || [];
|
const data = response.data.data;
|
||||||
|
this.searchResults = data.content || [];
|
||||||
this.searchPerformed = true;
|
this.searchPerformed = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -482,24 +487,31 @@ export default {
|
|||||||
try {
|
try {
|
||||||
if (this.isEdit) {
|
if (this.isEdit) {
|
||||||
// 배너 수정
|
// 배너 수정
|
||||||
await updateCharacterBanner({
|
const response = await updateCharacterBanner({
|
||||||
image: this.bannerForm.image,
|
image: this.bannerForm.image,
|
||||||
characterId: this.selectedCharacter.id,
|
characterId: this.selectedCharacter.id,
|
||||||
bannerId: this.bannerForm.bannerId
|
bannerId: this.bannerForm.bannerId
|
||||||
});
|
});
|
||||||
|
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||||
this.notifySuccess('배너가 수정되었습니다.');
|
this.notifySuccess('배너가 수정되었습니다.');
|
||||||
|
} else {
|
||||||
|
this.notifyError('배너 수정을 실패했습니다.');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 배너 추가
|
// 배너 추가
|
||||||
await createCharacterBanner({
|
const response = await createCharacterBanner({
|
||||||
image: this.bannerForm.image,
|
image: this.bannerForm.image,
|
||||||
characterId: this.selectedCharacter.id
|
characterId: this.selectedCharacter.id
|
||||||
});
|
});
|
||||||
|
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||||
this.notifySuccess('배너가 추가되었습니다.');
|
this.notifySuccess('배너가 추가되었습니다.');
|
||||||
}
|
|
||||||
|
|
||||||
// 다이얼로그 닫고 배너 목록 새로고침
|
// 다이얼로그 닫고 배너 목록 새로고침
|
||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
this.refreshBanners();
|
this.refreshBanners();
|
||||||
|
} else {
|
||||||
|
this.notifyError('배너 추가를 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('배너 저장 오류:', error);
|
console.error('배너 저장 오류:', error);
|
||||||
this.notifyError('배너 저장에 실패했습니다.');
|
this.notifyError('배너 저장에 실패했습니다.');
|
||||||
@@ -514,10 +526,14 @@ export default {
|
|||||||
this.isSubmitting = true;
|
this.isSubmitting = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteCharacterBanner(this.selectedBanner.id);
|
const response = await deleteCharacterBanner(this.selectedBanner.id);
|
||||||
|
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||||
this.notifySuccess('배너가 삭제되었습니다.');
|
this.notifySuccess('배너가 삭제되었습니다.');
|
||||||
this.showDeleteDialog = false;
|
this.showDeleteDialog = false;
|
||||||
this.refreshBanners();
|
this.refreshBanners();
|
||||||
|
} else {
|
||||||
|
this.notifyError('배너 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('배너 삭제 오류:', error);
|
console.error('배너 삭제 오류:', error);
|
||||||
this.notifyError('배너 삭제에 실패했습니다.');
|
this.notifyError('배너 삭제에 실패했습니다.');
|
||||||
@@ -538,8 +554,12 @@ export default {
|
|||||||
// 드래그 앤 드롭으로 순서 변경 후 API 호출
|
// 드래그 앤 드롭으로 순서 변경 후 API 호출
|
||||||
try {
|
try {
|
||||||
const bannerIds = this.banners.map(banner => banner.id);
|
const bannerIds = this.banners.map(banner => banner.id);
|
||||||
await updateCharacterBannerOrder(bannerIds);
|
const response = await updateCharacterBannerOrder(bannerIds);
|
||||||
|
if (response && response.status === 200 && response.data && response.data.success === true) {
|
||||||
this.notifySuccess('배너 순서가 변경되었습니다.');
|
this.notifySuccess('배너 순서가 변경되었습니다.');
|
||||||
|
} else {
|
||||||
|
this.notifyError('배너 순서 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('배너 순서 변경 오류:', error);
|
console.error('배너 순서 변경 오류:', error);
|
||||||
this.notifyError('배너 순서 변경에 실패했습니다.');
|
this.notifyError('배너 순서 변경에 실패했습니다.');
|
||||||
|
315
src/views/Chat/CharacterCalculateList.vue
Normal file
315
src/views/Chat/CharacterCalculateList.vue
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>캐릭터 정산</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-row class="justify-center align-center text-center">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<v-menu
|
||||||
|
ref="menuStart"
|
||||||
|
v-model="menuStart"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
transition="scale-transition"
|
||||||
|
offset-y
|
||||||
|
min-width="auto"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ on, attrs }">
|
||||||
|
<v-text-field
|
||||||
|
v-model="filters.startDateStr"
|
||||||
|
label="시작일"
|
||||||
|
readonly
|
||||||
|
dense
|
||||||
|
v-bind="attrs"
|
||||||
|
clearable
|
||||||
|
v-on="on"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
v-model="filters.startDateStr"
|
||||||
|
:max="filters.endDateStr && filters.endDateStr < todayStr ? filters.endDateStr : todayStr"
|
||||||
|
locale="ko-kr"
|
||||||
|
@input="$refs.menuStart.save(filters.startDateStr)"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<v-menu
|
||||||
|
ref="menuEnd"
|
||||||
|
v-model="menuEnd"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
transition="scale-transition"
|
||||||
|
offset-y
|
||||||
|
min-width="auto"
|
||||||
|
>
|
||||||
|
<template v-slot:activator="{ on, attrs }">
|
||||||
|
<v-text-field
|
||||||
|
v-model="filters.endDateStr"
|
||||||
|
label="종료일"
|
||||||
|
readonly
|
||||||
|
dense
|
||||||
|
v-bind="attrs"
|
||||||
|
clearable
|
||||||
|
v-on="on"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
v-model="filters.endDateStr"
|
||||||
|
:min="filters.startDateStr"
|
||||||
|
:max="todayStr"
|
||||||
|
locale="ko-kr"
|
||||||
|
@input="$refs.menuEnd.save(filters.endDateStr)"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
>
|
||||||
|
<v-select
|
||||||
|
v-model="filters.sort"
|
||||||
|
:items="sortItems"
|
||||||
|
label="정렬"
|
||||||
|
item-text="text"
|
||||||
|
item-value="value"
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="3"
|
||||||
|
class="d-flex justify-center align-center"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
small
|
||||||
|
:loading="is_loading"
|
||||||
|
@click="fetchList(1)"
|
||||||
|
>
|
||||||
|
조회
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
class="ml-2"
|
||||||
|
text
|
||||||
|
small
|
||||||
|
@click="resetFilters"
|
||||||
|
>
|
||||||
|
초기화
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-simple-table class="elevation-10 text-center">
|
||||||
|
<template>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-center">
|
||||||
|
이미지
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
캐릭터명
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
이미지 단독 구매(캔)
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
메시지 구매(캔)
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
채팅 횟수 구매(캔)
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
합계(캔)
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
합계(원화)
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
정산금액(원)
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.characterId"
|
||||||
|
>
|
||||||
|
<td align="center">
|
||||||
|
<v-img
|
||||||
|
:src="item.characterImage"
|
||||||
|
max-width="64"
|
||||||
|
max-height="64"
|
||||||
|
class="rounded-circle"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{{ item.name }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{{ formatNumber(item.imagePurchaseCan) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{{ formatNumber(item.messagePurchaseCan) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{{ formatNumber(item.quotaPurchaseCan) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center font-weight-bold">
|
||||||
|
{{ formatNumber(item.totalCan) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{{ formatCurrency(item.totalKrw) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-center font-weight-bold">
|
||||||
|
{{ formatCurrency(item.settlementKrw) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!is_loading && items.length === 0">
|
||||||
|
<td
|
||||||
|
colspan="7"
|
||||||
|
class="text-center grey--text"
|
||||||
|
>
|
||||||
|
데이터가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</v-simple-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row class="text-center">
|
||||||
|
<v-col>
|
||||||
|
<v-pagination
|
||||||
|
v-model="page"
|
||||||
|
:length="total_page"
|
||||||
|
circle
|
||||||
|
@input="onPageChange"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getCharacterCalculateList } from "@/api/character";
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
const pad = (n) => n.toString().padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "CharacterCalculateList",
|
||||||
|
data() {
|
||||||
|
const today = new Date();
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(today.getDate() - 7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_loading: false,
|
||||||
|
menuStart: false,
|
||||||
|
menuEnd: false,
|
||||||
|
todayStr: formatDate(today),
|
||||||
|
page: 1,
|
||||||
|
size: 30,
|
||||||
|
total_page: 1,
|
||||||
|
total_count: 0,
|
||||||
|
items: [],
|
||||||
|
sortItems: [
|
||||||
|
{ text: "매출순", value: "TOTAL_SALES_DESC" },
|
||||||
|
{ text: "최신캐릭터순", value: "LATEST_DESC" }
|
||||||
|
],
|
||||||
|
filters: {
|
||||||
|
startDateStr: formatDate(new Date(today.getFullYear(), today.getMonth(), 1)),
|
||||||
|
endDateStr: formatDate(today),
|
||||||
|
sort: "TOTAL_SALES_DESC"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchList(1);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) {
|
||||||
|
this.$dialog && this.$dialog.notify ? this.$dialog.notify.error(message) : alert(message);
|
||||||
|
},
|
||||||
|
onPageChange() {
|
||||||
|
this.fetchList(this.page);
|
||||||
|
},
|
||||||
|
resetFilters() {
|
||||||
|
const today = new Date();
|
||||||
|
// 이번 달 1일로 시작일 설정
|
||||||
|
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
this.filters.startDateStr = formatDate(firstDay);
|
||||||
|
// 종료일은 오늘
|
||||||
|
this.filters.endDateStr = formatDate(today);
|
||||||
|
this.filters.sort = "TOTAL_SALES_DESC";
|
||||||
|
// 페이지를 1로 리셋하고 목록 조회
|
||||||
|
this.fetchList(1);
|
||||||
|
},
|
||||||
|
async fetchList(page = 1) {
|
||||||
|
if (this.is_loading) return;
|
||||||
|
this.is_loading = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
startDateStr: this.filters.startDateStr || null,
|
||||||
|
endDateStr: this.filters.endDateStr || null,
|
||||||
|
sort: this.filters.sort,
|
||||||
|
page: (page - 1),
|
||||||
|
size: this.size
|
||||||
|
};
|
||||||
|
const res = await getCharacterCalculateList(params);
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
const data = res.data && res.data.data ? res.data.data : res.data;
|
||||||
|
if (data) {
|
||||||
|
this.total_count = data.totalCount || 0;
|
||||||
|
this.items = data.items || [];
|
||||||
|
const totalPage = Math.ceil(this.total_count / this.size);
|
||||||
|
this.total_page = totalPage > 0 ? totalPage : 1;
|
||||||
|
this.page = page;
|
||||||
|
} else {
|
||||||
|
this.items = [];
|
||||||
|
this.total_count = 0;
|
||||||
|
this.total_page = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.notifyError("목록 조회 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("정산 목록 조회 오류:", e);
|
||||||
|
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.");
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatNumber(n) {
|
||||||
|
const num = Number(n || 0);
|
||||||
|
return num.toLocaleString("ko-KR");
|
||||||
|
},
|
||||||
|
formatCurrency(n) {
|
||||||
|
const num = Number(n || 0);
|
||||||
|
return num.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-simple-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
341
src/views/Chat/CharacterCuration.vue
Normal file
341
src/views/Chat/CharacterCuration.vue
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>캐릭터 큐레이션</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
sm="4"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
dark
|
||||||
|
@click="showWriteDialog"
|
||||||
|
>
|
||||||
|
큐레이션 등록
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-data-table
|
||||||
|
:headers="headers"
|
||||||
|
:items="curations"
|
||||||
|
:loading="isLoading"
|
||||||
|
item-key="id"
|
||||||
|
class="elevation-1"
|
||||||
|
hide-default-footer
|
||||||
|
disable-pagination
|
||||||
|
>
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<draggable
|
||||||
|
v-model="props.items"
|
||||||
|
tag="tbody"
|
||||||
|
@end="onDragEnd(props.items)"
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
v-for="item in props.items"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<td @click="goDetail(item)">
|
||||||
|
{{ item.title }}
|
||||||
|
</td>
|
||||||
|
<td @click="goDetail(item)">
|
||||||
|
<h3 v-if="item.isAdult">
|
||||||
|
O
|
||||||
|
</h3>
|
||||||
|
<h3 v-else>
|
||||||
|
X
|
||||||
|
</h3>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="primary"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="showModifyDialog(item)"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="confirmDelete(item)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</draggable>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<!-- 등록/수정 다이얼로그 -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDialog"
|
||||||
|
max-width="600px"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>
|
||||||
|
<span class="headline">{{ isModify ? '큐레이션 수정' : '큐레이션 등록' }}</span>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-container>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.title"
|
||||||
|
label="제목"
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="form.isAdult"
|
||||||
|
label="19금"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
@click="closeDialog"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
:disabled="!isFormValid || isSubmitting"
|
||||||
|
@click="saveCuration"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 삭제 확인 다이얼로그 -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDeleteDialog"
|
||||||
|
max-width="400px"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="headline">
|
||||||
|
큐레이션 삭제
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>"{{ selectedCuration && selectedCuration.title }}"을(를) 삭제하시겠습니까?</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
@click="showDeleteDialog = false"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="red darken-1"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="deleteCuration"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
|
import {
|
||||||
|
getCharacterCurationList,
|
||||||
|
createCharacterCuration,
|
||||||
|
updateCharacterCuration,
|
||||||
|
deleteCharacterCuration,
|
||||||
|
updateCharacterCurationOrder
|
||||||
|
} from '@/api/character';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CharacterCuration',
|
||||||
|
components: { draggable },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isLoading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
curations: [],
|
||||||
|
headers: [
|
||||||
|
{ text: '제목', align: 'center', sortable: false, value: 'title' },
|
||||||
|
{ text: '19금', align: 'center', sortable: false, value: 'isAdult' },
|
||||||
|
{ text: '관리', align: 'center', sortable: false, value: 'management' }
|
||||||
|
],
|
||||||
|
showDialog: false,
|
||||||
|
isModify: false,
|
||||||
|
form: { id: null, title: '', isAdult: false },
|
||||||
|
selectedCuration: null,
|
||||||
|
showDeleteDialog: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isFormValid() {
|
||||||
|
return this.form.title && this.form.title.trim().length > 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
await this.loadCurations();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) { this.$dialog.notify.error(message); },
|
||||||
|
notifySuccess(message) { this.$dialog.notify.success(message); },
|
||||||
|
|
||||||
|
async loadCurations() {
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await getCharacterCurationList();
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.curations = res.data.data || [];
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '목록을 불러오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('목록을 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragEnd(items) {
|
||||||
|
const ids = items.map(i => i.id);
|
||||||
|
this.updateOrders(ids);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateOrders(ids) {
|
||||||
|
try {
|
||||||
|
const res = await updateCharacterCurationOrder(ids);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess('순서가 변경되었습니다.');
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '순서 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('순서 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goDetail(item) {
|
||||||
|
this.$router.push({
|
||||||
|
name: 'CharacterCurationDetail',
|
||||||
|
params: { curationId: item.id, title: item.title, isAdult: item.isAdult }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showWriteDialog() {
|
||||||
|
this.isModify = false;
|
||||||
|
this.form = { id: null, title: '', isAdult: false };
|
||||||
|
this.showDialog = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
showModifyDialog(item) {
|
||||||
|
this.isModify = true;
|
||||||
|
this.form = { id: item.id, title: item.title, isAdult: item.isAdult };
|
||||||
|
this.showDialog = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeDialog() {
|
||||||
|
this.showDialog = false;
|
||||||
|
this.form = { id: null, title: '', isAdult: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveCuration() {
|
||||||
|
if (this.isSubmitting || !this.isFormValid) return;
|
||||||
|
this.isSubmitting = true;
|
||||||
|
try {
|
||||||
|
if (this.isModify) {
|
||||||
|
const payload = { id: this.form.id };
|
||||||
|
if (this.form.title) payload.title = this.form.title;
|
||||||
|
payload.isAdult = this.form.isAdult;
|
||||||
|
const res = await updateCharacterCuration(payload);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess('수정되었습니다.');
|
||||||
|
this.closeDialog();
|
||||||
|
await this.loadCurations();
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '수정에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await createCharacterCuration({
|
||||||
|
title: this.form.title,
|
||||||
|
isAdult: this.form.isAdult,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess('등록되었습니다.');
|
||||||
|
this.closeDialog();
|
||||||
|
await this.loadCurations();
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '등록에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmDelete(item) {
|
||||||
|
this.selectedCuration = item;
|
||||||
|
this.showDeleteDialog = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCuration() {
|
||||||
|
if (!this.selectedCuration) return;
|
||||||
|
this.isSubmitting = true;
|
||||||
|
try {
|
||||||
|
const res = await deleteCharacterCuration(this.selectedCuration.id);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess('삭제되었습니다.');
|
||||||
|
this.showDeleteDialog = false;
|
||||||
|
await this.loadCurations();
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('삭제에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
429
src/views/Chat/CharacterCurationDetail.vue
Normal file
429
src/views/Chat/CharacterCurationDetail.vue
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-arrow-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-row class="mb-2">
|
||||||
|
<v-col
|
||||||
|
cols="4"
|
||||||
|
class="text-right"
|
||||||
|
>
|
||||||
|
19금 :
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="8">
|
||||||
|
{{ isAdult ? 'O' : 'X' }}
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row class="mb-4">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
sm="4"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
dark
|
||||||
|
@click="openAddDialog"
|
||||||
|
>
|
||||||
|
캐릭터 등록
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<draggable
|
||||||
|
v-model="characters"
|
||||||
|
class="row"
|
||||||
|
style="width: 100%"
|
||||||
|
:options="{ animation: 150 }"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<v-col
|
||||||
|
v-for="ch in characters"
|
||||||
|
:key="ch.id"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="4"
|
||||||
|
lg="3"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-img
|
||||||
|
:src="ch.imageUrl"
|
||||||
|
height="200"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
{{ ch.name }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
{{ ch.description }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
@click="confirmRemove(ch)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</draggable>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row v-if="isLoading && characters.length === 0">
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
size="48"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row v-if="!isLoading && characters.length === 0">
|
||||||
|
<v-col class="text-center">
|
||||||
|
등록된 캐릭터가 없습니다.
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<!-- 등록 다이얼로그 -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showAddDialog"
|
||||||
|
max-width="700px"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>캐릭터 등록</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchWord"
|
||||||
|
label="캐릭터 검색"
|
||||||
|
outlined
|
||||||
|
@keyup.enter="search"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
small
|
||||||
|
class="mb-2"
|
||||||
|
@click="search"
|
||||||
|
>
|
||||||
|
검색
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-row v-if="searchResults.length > 0 || addList.length > 0">
|
||||||
|
<v-col>
|
||||||
|
검색결과
|
||||||
|
<v-simple-table>
|
||||||
|
<template v-slot:default>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-center">
|
||||||
|
이름
|
||||||
|
</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in searchResults"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="primary"
|
||||||
|
@click="addItem(item)"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</v-simple-table>
|
||||||
|
</v-col>
|
||||||
|
<v-col v-if="addList.length > 0">
|
||||||
|
추가할 캐릭터
|
||||||
|
<v-simple-table>
|
||||||
|
<template>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-center">
|
||||||
|
이름
|
||||||
|
</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in addList"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
@click="removeItem(item)"
|
||||||
|
>
|
||||||
|
제거
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
|
</v-simple-table>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-else-if="searchPerformed"
|
||||||
|
type="info"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
검색결과가 없습니다.
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
@click="closeAddDialog"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
:disabled="addList.length === 0 || isSubmitting"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="addItemInCuration"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 삭제 확인 다이얼로그 -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDeleteDialog"
|
||||||
|
max-width="420px"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="headline">
|
||||||
|
캐릭터 삭제
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>"{{ targetCharacter && targetCharacter.name }}"을(를) 큐레이션에서 삭제할까요?</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
@click="showDeleteDialog = false"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="red darken-1"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="removeTarget"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
|
import {
|
||||||
|
getCharactersInCuration,
|
||||||
|
addCharacterToCuration,
|
||||||
|
removeCharacterFromCuration,
|
||||||
|
updateCurationCharactersOrder,
|
||||||
|
searchCharacters
|
||||||
|
} from '@/api/character';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CharacterCurationDetail',
|
||||||
|
components: { draggable },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isLoading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
curationId: null,
|
||||||
|
title: '',
|
||||||
|
isAdult: false,
|
||||||
|
characters: [],
|
||||||
|
showAddDialog: false,
|
||||||
|
showDeleteDialog: false,
|
||||||
|
targetCharacter: null,
|
||||||
|
searchWord: '',
|
||||||
|
searchResults: [],
|
||||||
|
searchPerformed: false,
|
||||||
|
addList: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async created() {
|
||||||
|
this.curationId = this.$route.params.curationId;
|
||||||
|
this.title = this.$route.params.title;
|
||||||
|
this.isAdult = this.$route.params.isAdult;
|
||||||
|
await this.loadCharacters();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) { this.$dialog.notify.error(message); },
|
||||||
|
notifySuccess(message) { this.$dialog.notify.success(message); },
|
||||||
|
|
||||||
|
goBack() { this.$router.push({ name: 'CharacterCuration' }); },
|
||||||
|
|
||||||
|
async loadCharacters() {
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await getCharactersInCuration(this.curationId);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.characters = res.data.data || [];
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '캐릭터 목록을 불러오지 못했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('캐릭터 목록을 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openAddDialog() {
|
||||||
|
this.showAddDialog = true;
|
||||||
|
this.searchWord = '';
|
||||||
|
this.searchResults = [];
|
||||||
|
this.addList = [];
|
||||||
|
this.searchPerformed = false;
|
||||||
|
},
|
||||||
|
closeAddDialog() {
|
||||||
|
this.showAddDialog = false;
|
||||||
|
this.searchWord = '';
|
||||||
|
this.searchResults = [];
|
||||||
|
this.addList = [];
|
||||||
|
this.searchPerformed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
if (!this.searchWord || this.searchWord.length < 2) {
|
||||||
|
this.notifyError('검색어를 2글자 이상 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await searchCharacters(this.searchWord);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
const data = res.data.data;
|
||||||
|
const list = data.content || [];
|
||||||
|
const existingIds = new Set(this.characters.map(c => c.id));
|
||||||
|
const pendingIds = new Set(this.addList.map(c => c.id));
|
||||||
|
this.searchResults = list.filter(item => !existingIds.has(item.id) && !pendingIds.has(item.id));
|
||||||
|
this.searchPerformed = true;
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '검색에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('검색에 실패했습니다.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addItem(item) {
|
||||||
|
// 검색결과에서 제거하고 추가 목록에 삽입 (중복 방지)
|
||||||
|
if (!this.addList.find(t => t.id === item.id)) {
|
||||||
|
this.addList.push(item);
|
||||||
|
}
|
||||||
|
this.searchResults = this.searchResults.filter(t => t.id !== item.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem(item) {
|
||||||
|
this.addList = this.addList.filter(t => t.id !== item.id);
|
||||||
|
// 제거 시 검색결과에 다시 추가
|
||||||
|
if (!this.searchResults.find(t => t.id === item.id)) {
|
||||||
|
this.searchResults.push(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addItemInCuration() {
|
||||||
|
if (!this.addList || this.addList.length === 0) return;
|
||||||
|
this.isSubmitting = true;
|
||||||
|
try {
|
||||||
|
const ids = this.addList.map(i => i.id);
|
||||||
|
const res = await addCharacterToCuration(this.curationId, ids);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess(`${this.addList.length}명 추가되었습니다.`);
|
||||||
|
this.closeAddDialog();
|
||||||
|
await this.loadCharacters();
|
||||||
|
} else {
|
||||||
|
this.notifyError((res.data && res.data.message) || '추가에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('추가에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmRemove(item) { this.targetCharacter = item; this.showDeleteDialog = true; },
|
||||||
|
|
||||||
|
async removeTarget() {
|
||||||
|
if (!this.targetCharacter) return;
|
||||||
|
this.isSubmitting = true;
|
||||||
|
try {
|
||||||
|
const res = await removeCharacterFromCuration(this.curationId, this.targetCharacter.id);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess('삭제되었습니다.');
|
||||||
|
this.showDeleteDialog = false;
|
||||||
|
await this.loadCharacters();
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('삭제에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onDragEnd() {
|
||||||
|
try {
|
||||||
|
const ids = this.characters.map(c => c.id);
|
||||||
|
const res = await updateCurationCharactersOrder(this.curationId, ids);
|
||||||
|
if (res.status === 200 && res.data && res.data.success === true) {
|
||||||
|
this.notifySuccess('순서가 변경되었습니다.');
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '순서 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('순서 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
File diff suppressed because it is too large
Load Diff
306
src/views/Chat/CharacterImageForm.vue
Normal file
306
src/views/Chat/CharacterImageForm.vue
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-arrow-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>{{ isEdit ? '이미지 수정' : '이미지 등록' }}</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-card class="pa-4">
|
||||||
|
<v-form
|
||||||
|
ref="form"
|
||||||
|
v-model="isFormValid"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
v-show="!isEdit"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-file-input
|
||||||
|
v-if="!isEdit"
|
||||||
|
v-model="form.image"
|
||||||
|
label="이미지 (800x1000 비율 권장)"
|
||||||
|
accept="image/*"
|
||||||
|
prepend-icon="mdi-camera"
|
||||||
|
show-size
|
||||||
|
truncate-length="15"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="imageRules"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
v-if="previewImage || form.imageUrl"
|
||||||
|
cols="12"
|
||||||
|
:md="isEdit ? 12 : 6"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<v-img
|
||||||
|
:src="previewImage || form.imageUrl"
|
||||||
|
max-height="240"
|
||||||
|
:aspect-ratio="0.8"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.soloPurchasePriceCan"
|
||||||
|
label="이미지 단독 구매 가격(캔)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:disabled="isEdit"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model.number="form.messagePurchasePriceCan"
|
||||||
|
label="메시지에서 구매 가격(캔)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:disabled="isEdit"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-switch
|
||||||
|
v-model="form.adult"
|
||||||
|
label="성인 이미지 여부"
|
||||||
|
inset
|
||||||
|
:disabled="isEdit"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-combobox
|
||||||
|
v-model="triggers"
|
||||||
|
label="트리거 단어 입력"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
small-chips
|
||||||
|
deletable-chips
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="triggerRules"
|
||||||
|
@keydown.space.prevent="addTrigger"
|
||||||
|
>
|
||||||
|
<template v-slot:selection="{ attrs, item, select, selected }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="attrs"
|
||||||
|
:input-value="selected"
|
||||||
|
close
|
||||||
|
@click="select"
|
||||||
|
@click:close="removeTrigger(item)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-combobox>
|
||||||
|
<div class="caption grey--text text--darken-1">
|
||||||
|
트리거를 입력하고 엔터를 누르면 추가됩니다. (20자 이내, 최소 3개, 최대 10개)
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
:disabled="!canSubmit || isSubmitting"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-form>
|
||||||
|
</v-card>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createCharacterImage, updateCharacterImage, getCharacterImage } from '@/api/character'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CharacterImageForm',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isEdit: !!this.$route.query.imageId,
|
||||||
|
isSubmitting: false,
|
||||||
|
isFormValid: false,
|
||||||
|
characterId: Number(this.$route.query.characterId),
|
||||||
|
imageId: this.$route.query.imageId ? Number(this.$route.query.imageId) : null,
|
||||||
|
form: {
|
||||||
|
image: null,
|
||||||
|
imageUrl: '',
|
||||||
|
soloPurchasePriceCan: null,
|
||||||
|
messagePurchasePriceCan: null,
|
||||||
|
adult: false
|
||||||
|
},
|
||||||
|
previewImage: null,
|
||||||
|
triggers: [],
|
||||||
|
triggerRules: [
|
||||||
|
v => (v && v.length >= 3 && v.length <= 10) || '트리거는 최소 3개, 최대 10개까지 등록 가능합니다'
|
||||||
|
],
|
||||||
|
imageRules: [
|
||||||
|
v => !!v || '이미지를 선택하세요'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canSubmit() {
|
||||||
|
const triggersValid = this.triggers && this.triggers.length >= 3 && this.triggers.length <= 10
|
||||||
|
if (this.isEdit) return triggersValid
|
||||||
|
return !!this.form.image && triggersValid
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'form.image'(newVal) {
|
||||||
|
if (!this.isEdit) {
|
||||||
|
if (newVal) this.createImagePreview(newVal)
|
||||||
|
else this.previewImage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
if (!this.characterId) {
|
||||||
|
this.notifyError('캐릭터 ID가 없습니다.')
|
||||||
|
this.goBack();
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.isEdit && this.imageId) {
|
||||||
|
this.loadDetail()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(m) { this.$dialog.notify.error(m) },
|
||||||
|
notifySuccess(m) { this.$dialog.notify.success(m) },
|
||||||
|
goBack() {
|
||||||
|
this.$router.push({ path: '/character/images', query: { characterId: this.characterId, name: this.$route.query.name || '' } })
|
||||||
|
},
|
||||||
|
createImagePreview(file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = e => { this.previewImage = e.target.result }
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
},
|
||||||
|
addTrigger(e) {
|
||||||
|
const value = (e.target.value || '').trim()
|
||||||
|
if (!value) return
|
||||||
|
if (value.length > 20) {
|
||||||
|
this.notifyError('트리거는 20자 이내여야 합니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.triggers.length >= 10) {
|
||||||
|
this.notifyError('트리거는 최대 10개까지 등록 가능합니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.triggers.includes(value)) this.triggers.push(value)
|
||||||
|
e.target.value = ''
|
||||||
|
},
|
||||||
|
removeTrigger(item) {
|
||||||
|
this.triggers = this.triggers.filter(t => t !== item)
|
||||||
|
},
|
||||||
|
async loadDetail() {
|
||||||
|
try {
|
||||||
|
const resp = await getCharacterImage(this.imageId)
|
||||||
|
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||||
|
const d = resp.data.data
|
||||||
|
// 수정 시 트리거만 노출하며 나머지는 비활성화
|
||||||
|
this.form.imageUrl = d.imageUrl
|
||||||
|
this.form.soloPurchasePriceCan = d.imagePriceCan
|
||||||
|
this.form.messagePurchasePriceCan = d.messagePriceCan
|
||||||
|
this.form.adult = d.isAdult
|
||||||
|
this.triggers = d.triggers || []
|
||||||
|
} else {
|
||||||
|
this.notifyError('이미지 정보를 불러오지 못했습니다.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('이미지 상세 오류:', e)
|
||||||
|
this.notifyError('이미지 정보를 불러오지 못했습니다.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (this.isSubmitting) return
|
||||||
|
// 트리거 개수 검증: 최소 3개, 최대 10개
|
||||||
|
if (!this.triggers || this.triggers.length < 3 || this.triggers.length > 10) {
|
||||||
|
this.notifyError('트리거는 최소 3개, 최대 10개여야 합니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.isSubmitting = true
|
||||||
|
try {
|
||||||
|
if (this.isEdit) {
|
||||||
|
const resp = await updateCharacterImage({ imageId: this.imageId, triggers: this.triggers })
|
||||||
|
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||||
|
this.notifySuccess('수정되었습니다.')
|
||||||
|
this.goBack()
|
||||||
|
} else {
|
||||||
|
this.notifyError('수정에 실패했습니다.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resp = await createCharacterImage({
|
||||||
|
characterId: this.characterId,
|
||||||
|
image: this.form.image,
|
||||||
|
imagePriceCan: this.form.soloPurchasePriceCan,
|
||||||
|
messagePriceCan: this.form.messagePurchasePriceCan,
|
||||||
|
isAdult: this.form.adult,
|
||||||
|
triggers: this.triggers
|
||||||
|
})
|
||||||
|
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||||
|
this.notifySuccess('등록되었습니다.')
|
||||||
|
this.goBack()
|
||||||
|
} else {
|
||||||
|
this.notifyError('등록에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('이미지 저장 오류:', e)
|
||||||
|
this.notifyError('작업 중 오류가 발생했습니다.')
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
325
src/views/Chat/CharacterImageList.vue
Normal file
325
src/views/Chat/CharacterImageList.vue
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-arrow-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>캐릭터 이미지 관리</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-row class="align-center mb-4">
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<div class="subtitle-1">
|
||||||
|
캐릭터: {{ characterName || characterId }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
class="text-right"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
dark
|
||||||
|
@click="goToAdd"
|
||||||
|
>
|
||||||
|
이미지 추가
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 로딩 -->
|
||||||
|
<v-row v-if="isLoading && images.length === 0">
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
size="48"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 목록 -->
|
||||||
|
<draggable
|
||||||
|
v-if="images.length > 0"
|
||||||
|
v-model="images"
|
||||||
|
class="image-grid"
|
||||||
|
:options="{ animation: 150 }"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="img in images"
|
||||||
|
:key="img.id"
|
||||||
|
class="image-card"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<div class="image-wrapper">
|
||||||
|
<v-img
|
||||||
|
:src="img.imageUrl"
|
||||||
|
:aspect-ratio="0.8"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="img.isAdult"
|
||||||
|
class="ribbon"
|
||||||
|
>
|
||||||
|
성인
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-card-text class="pt-2">
|
||||||
|
<div class="price-row d-flex align-center">
|
||||||
|
<div class="price-label">
|
||||||
|
단독 :
|
||||||
|
</div>
|
||||||
|
<div class="price-value">
|
||||||
|
{{ img.imagePriceCan }} 캔
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="price-row d-flex align-center">
|
||||||
|
<div class="price-label">
|
||||||
|
메시지 :
|
||||||
|
</div>
|
||||||
|
<div class="price-value">
|
||||||
|
{{ img.messagePriceCan }} 캔
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<v-chip
|
||||||
|
v-for="(t, i) in (img.triggers || [])"
|
||||||
|
:key="i"
|
||||||
|
small
|
||||||
|
class="ma-1"
|
||||||
|
color="primary"
|
||||||
|
text-color="white"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="primary"
|
||||||
|
@click="goToEdit(img)"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
@click="confirmDelete(img)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
|
<!-- 데이터 없음 -->
|
||||||
|
<v-row v-if="!isLoading && images.length === 0">
|
||||||
|
<v-col class="text-center grey--text">
|
||||||
|
등록된 이미지가 없습니다.
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 삭제 확인 다이얼로그 -->
|
||||||
|
<v-dialog
|
||||||
|
v-model="showDeleteDialog"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="headline">
|
||||||
|
이미지 삭제
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>삭제하시겠습니까?</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="blue darken-1"
|
||||||
|
@click="showDeleteDialog = false"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="red darken-1"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="deleteImage"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getCharacterImageList, deleteCharacterImage, updateCharacterImageOrder } from '@/api/character'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CharacterImageList',
|
||||||
|
components: { draggable },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isLoading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
images: [],
|
||||||
|
characterId: null,
|
||||||
|
characterName: this.$route.query.name || '',
|
||||||
|
showDeleteDialog: false,
|
||||||
|
selectedImage: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.characterId = Number(this.$route.query.characterId)
|
||||||
|
if (!this.characterId) {
|
||||||
|
this.notifyError('캐릭터 ID가 없습니다.');
|
||||||
|
this.goBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.loadImages()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) { this.$dialog.notify.error(message) },
|
||||||
|
notifySuccess(message) { this.$dialog.notify.success(message) },
|
||||||
|
goBack() { this.$router.push('/character') },
|
||||||
|
async loadImages() {
|
||||||
|
this.isLoading = true
|
||||||
|
try {
|
||||||
|
const resp = await getCharacterImageList(this.characterId, 1, 20)
|
||||||
|
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||||
|
const data = resp.data.data
|
||||||
|
this.images = (data.content || data || [])
|
||||||
|
} else {
|
||||||
|
this.notifyError('이미지 목록을 불러오지 못했습니다.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('이미지 목록 오류:', e)
|
||||||
|
this.notifyError('이미지 목록 조회 중 오류가 발생했습니다.')
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToAdd() {
|
||||||
|
this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, name: this.characterName } })
|
||||||
|
},
|
||||||
|
goToEdit(img) {
|
||||||
|
this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, imageId: img.id, name: this.characterName } })
|
||||||
|
},
|
||||||
|
confirmDelete(img) {
|
||||||
|
this.selectedImage = img
|
||||||
|
this.showDeleteDialog = true
|
||||||
|
},
|
||||||
|
async deleteImage() {
|
||||||
|
if (!this.selectedImage || this.isSubmitting) return
|
||||||
|
this.isSubmitting = true
|
||||||
|
try {
|
||||||
|
const resp = await deleteCharacterImage(this.selectedImage.id)
|
||||||
|
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||||
|
this.notifySuccess('삭제되었습니다.')
|
||||||
|
this.showDeleteDialog = false
|
||||||
|
await this.loadImages()
|
||||||
|
} else {
|
||||||
|
this.notifyError('삭제에 실패했습니다.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('이미지 삭제 오류:', e)
|
||||||
|
this.notifyError('삭제 중 오류가 발생했습니다.')
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onDragEnd() {
|
||||||
|
try {
|
||||||
|
const ids = this.images.map(img => img.id)
|
||||||
|
const resp = await updateCharacterImageOrder(this.characterId, ids)
|
||||||
|
if (resp && resp.status === 200 && resp.data && resp.data.success === true) {
|
||||||
|
this.notifySuccess('이미지 순서가 변경되었습니다.')
|
||||||
|
} else {
|
||||||
|
this.notifyError('이미지 순서 변경에 실패했습니다.')
|
||||||
|
await this.loadImages()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('이미지 순서 변경 오류:', e)
|
||||||
|
this.notifyError('이미지 순서 변경에 실패했습니다.')
|
||||||
|
await this.loadImages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.image-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (max-width: 1264px) {
|
||||||
|
.image-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.image-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.image-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image wrapper for overlays */
|
||||||
|
.image-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ribbon style for adult indicator */
|
||||||
|
.ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #e53935; /* red darken-1 */
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: none;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Price rows styling */
|
||||||
|
.price-row {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.price-label {
|
||||||
|
width: 72px; /* 긴 쪽 기준으로 라벨 고정폭 */
|
||||||
|
text-align: left;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.price-value {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(0,0,0,0.87);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -9,7 +9,7 @@
|
|||||||
<br>
|
<br>
|
||||||
|
|
||||||
<v-container>
|
<v-container>
|
||||||
<v-row>
|
<v-row align="center">
|
||||||
<v-col cols="4">
|
<v-col cols="4">
|
||||||
<v-btn
|
<v-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -19,22 +19,28 @@
|
|||||||
캐릭터 추가
|
캐릭터 추가
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-spacer />
|
<v-col
|
||||||
<v-col cols="6">
|
cols="8"
|
||||||
<v-text-field
|
class="d-flex justify-end align-center"
|
||||||
v-model="search_word"
|
|
||||||
label="캐릭터 이름 검색"
|
|
||||||
@keyup.enter="search"
|
|
||||||
>
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchTerm"
|
||||||
|
label="검색어"
|
||||||
|
placeholder="캐릭터명, 태그, mbti 검색"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
hide-details
|
||||||
|
style="max-width: 320px;"
|
||||||
|
class="mr-2"
|
||||||
|
@keyup.enter="onSearch"
|
||||||
|
/>
|
||||||
<v-btn
|
<v-btn
|
||||||
slot="append"
|
:style="{ backgroundColor: '#3bb9f1', color: 'white' }"
|
||||||
color="#9970ff"
|
:disabled="is_loading"
|
||||||
dark
|
@click="onSearch"
|
||||||
@click="search"
|
|
||||||
>
|
>
|
||||||
검색
|
검색
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-text-field>
|
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row>
|
<v-row>
|
||||||
@@ -158,6 +164,16 @@
|
|||||||
수정
|
수정
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="info"
|
||||||
|
:disabled="is_loading"
|
||||||
|
@click="goToImageList(item)"
|
||||||
|
>
|
||||||
|
이미지
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
<v-col>
|
<v-col>
|
||||||
<v-btn
|
<v-btn
|
||||||
small
|
small
|
||||||
@@ -251,7 +267,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getCharacterList, searchCharacters, updateCharacter } from '@/api/character'
|
import { getCharacterList, updateCharacter, searchCharacterList } from '@/api/character'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "CharacterList",
|
name: "CharacterList",
|
||||||
@@ -266,9 +282,9 @@ export default {
|
|||||||
detail_title: '',
|
detail_title: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
total_page: 0,
|
total_page: 0,
|
||||||
search_word: '',
|
|
||||||
characters: [],
|
characters: [],
|
||||||
selected_character: {}
|
selected_character: {},
|
||||||
|
searchTerm: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -324,6 +340,13 @@ export default {
|
|||||||
this.$router.push('/character/form');
|
this.$router.push('/character/form');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goToImageList(item) {
|
||||||
|
this.$router.push({
|
||||||
|
path: '/character/images',
|
||||||
|
query: { characterId: item.id, name: item.name }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
showEditDialog(item) {
|
showEditDialog(item) {
|
||||||
// 페이지로 이동하면서 id 전달
|
// 페이지로 이동하면서 id 전달
|
||||||
this.$router.push({
|
this.$router.push({
|
||||||
@@ -367,21 +390,25 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async next() {
|
async next() {
|
||||||
if (this.search_word.length < 2) {
|
|
||||||
this.search_word = ''
|
|
||||||
await this.getCharacters()
|
await this.getCharacters()
|
||||||
} else {
|
},
|
||||||
await this.searchCharacters()
|
|
||||||
}
|
onSearch() {
|
||||||
|
this.page = 1;
|
||||||
|
this.getCharacters();
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCharacters() {
|
async getCharacters() {
|
||||||
this.is_loading = true
|
this.is_loading = true
|
||||||
try {
|
try {
|
||||||
const response = await getCharacterList(this.page);
|
const hasSearch = this.searchTerm && this.searchTerm.trim() !== '';
|
||||||
|
const response = hasSearch
|
||||||
|
? await searchCharacterList(this.searchTerm.trim(), this.page, 20)
|
||||||
|
: await getCharacterList(this.page);
|
||||||
|
|
||||||
if (response && response.data) {
|
if (response && response.status === 200) {
|
||||||
const data = response.data;
|
if (response.data.success === true) {
|
||||||
|
const data = response.data.data;
|
||||||
this.characters = data.content || [];
|
this.characters = data.content || [];
|
||||||
|
|
||||||
const total_page = Math.ceil((data.totalCount || 0) / 20);
|
const total_page = Math.ceil((data.totalCount || 0) / 20);
|
||||||
@@ -389,6 +416,9 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.notifyError('응답 데이터가 없습니다.');
|
this.notifyError('응답 데이터가 없습니다.');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('캐릭터 목록 조회 오류:', e);
|
console.error('캐릭터 목록 조회 오류:', e);
|
||||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
||||||
@@ -397,38 +427,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async search() {
|
|
||||||
this.page = 1
|
|
||||||
await this.searchCharacters()
|
|
||||||
},
|
|
||||||
|
|
||||||
async searchCharacters() {
|
|
||||||
if (this.search_word.length === 0) {
|
|
||||||
await this.getCharacters()
|
|
||||||
} else if (this.search_word.length < 2) {
|
|
||||||
this.notifyError('검색어를 2글자 이상 입력하세요.')
|
|
||||||
} else {
|
|
||||||
this.is_loading = true
|
|
||||||
try {
|
|
||||||
const response = await searchCharacters(this.search_word, this.page);
|
|
||||||
|
|
||||||
if (response && response.data) {
|
|
||||||
const data = response.data;
|
|
||||||
this.characters = data.content || [];
|
|
||||||
|
|
||||||
const total_page = Math.ceil((data.totalCount || 0) / 20);
|
|
||||||
this.total_page = total_page <= 0 ? 1 : total_page;
|
|
||||||
} else {
|
|
||||||
this.notifyError('응답 데이터가 없습니다.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('캐릭터 검색 오류:', e);
|
|
||||||
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
|
|
||||||
} finally {
|
|
||||||
this.is_loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
356
src/views/Chat/OriginalDetail.vue
Normal file
356
src/views/Chat/OriginalDetail.vue
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-arrow-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>원작 상세</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
@click="openAssignDialog"
|
||||||
|
>
|
||||||
|
캐릭터 연결
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-card
|
||||||
|
v-if="detail"
|
||||||
|
class="pa-4"
|
||||||
|
>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
:src="detail.imageUrl"
|
||||||
|
contain
|
||||||
|
height="240"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="8"
|
||||||
|
>
|
||||||
|
<h2>{{ detail.title }}</h2>
|
||||||
|
<div class="mt-2">
|
||||||
|
콘텐츠 타입: {{ detail.contentType || '-' }}
|
||||||
|
</div>
|
||||||
|
<div>카테고리(장르): {{ detail.category || '-' }}</div>
|
||||||
|
<div>19금 여부: {{ detail.isAdult ? '예' : '아니오' }}</div>
|
||||||
|
<div>원천 원작: {{ detail.originalWork || '-' }}</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
원천 원작 링크:
|
||||||
|
<a
|
||||||
|
v-if="detail.originalLink"
|
||||||
|
:href="detail.originalLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ detail.originalLink }}</a>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</div>
|
||||||
|
<div>글/그림: {{ detail.writer || '-' }}</div>
|
||||||
|
<div>제작사: {{ detail.studio || '-' }}</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
원작 링크:
|
||||||
|
<template v-if="detail.originalLinks && detail.originalLinks.length">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="(link, idx) in detail.originalLinks"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ link }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
태그:
|
||||||
|
<template v-if="detail.tags && detail.tags.length">
|
||||||
|
<v-chip
|
||||||
|
v-for="(t, i) in detail.tags"
|
||||||
|
:key="i"
|
||||||
|
small
|
||||||
|
class="mr-1 mb-1"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
작품 소개:
|
||||||
|
</div>
|
||||||
|
<div style="white-space:pre-wrap;">
|
||||||
|
{{ detail.description || '-' }}
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="pa-4 mt-6">
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<h3>연결된 캐릭터</h3>
|
||||||
|
<v-spacer />
|
||||||
|
</div>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
v-for="c in characters"
|
||||||
|
:key="c.id"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="4"
|
||||||
|
lg="3"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-img
|
||||||
|
:src="c.imagePath"
|
||||||
|
height="180"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
<v-card-title class="text-no-wrap">
|
||||||
|
{{ c.name }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
@click="unassign([c.id])"
|
||||||
|
>
|
||||||
|
해제
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row v-if="isLoadingCharacters">
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card>
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="assignDialog"
|
||||||
|
max-width="800"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>캐릭터 연결</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="searchKeyword"
|
||||||
|
label="캐릭터 검색"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
@input="onSearchInput"
|
||||||
|
/>
|
||||||
|
<v-data-table
|
||||||
|
v-model="selectedToAssign"
|
||||||
|
:headers="headers"
|
||||||
|
:items="searchResults"
|
||||||
|
:loading="searchLoading"
|
||||||
|
item-key="id"
|
||||||
|
show-select
|
||||||
|
:items-per-page="5"
|
||||||
|
>
|
||||||
|
<template v-slot:item.imageUrl="{ item }">
|
||||||
|
<v-img
|
||||||
|
:src="item.imagePath"
|
||||||
|
max-width="60"
|
||||||
|
max-height="60"
|
||||||
|
class="rounded-circle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
@click="assignDialog = false"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="selectedToAssign.length===0"
|
||||||
|
@click="assign"
|
||||||
|
>
|
||||||
|
연결
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getOriginal, getOriginalCharacters, assignCharactersToOriginal, unassignCharactersFromOriginal } from '@/api/original'
|
||||||
|
import { searchCharacters } from '@/api/character'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OriginalDetail',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
detail: null,
|
||||||
|
characters: [],
|
||||||
|
page: 1,
|
||||||
|
hasMore: true,
|
||||||
|
isLoadingCharacters: false,
|
||||||
|
assignDialog: false,
|
||||||
|
searchKeyword: '',
|
||||||
|
searchLoading: false,
|
||||||
|
searchResults: [],
|
||||||
|
selectedToAssign: [],
|
||||||
|
headers: [
|
||||||
|
{ text: '이미지', value: 'imageUrl', sortable: false },
|
||||||
|
{ text: '이름', value: 'name' },
|
||||||
|
{ text: 'ID', value: 'id' }
|
||||||
|
],
|
||||||
|
debounceTimer: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.id = this.$route.query.id
|
||||||
|
if (!this.id) {
|
||||||
|
this.$dialog.notify.error('잘못된 접근입니다.');
|
||||||
|
this.$router.push('/original-work');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadDetail();
|
||||||
|
this.loadCharacters();
|
||||||
|
window.addEventListener('scroll', this.onScroll);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('scroll', this.onScroll);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) { this.$dialog.notify.error(message) },
|
||||||
|
notifySuccess(message) { this.$dialog.notify.success(message) },
|
||||||
|
goBack() { this.$router.push('/original-work') },
|
||||||
|
async loadDetail() {
|
||||||
|
try {
|
||||||
|
const res = await getOriginal(this.id);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.detail = res.data.data;
|
||||||
|
} else {
|
||||||
|
this.notifyError('상세 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('상세 조회 실패');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadCharacters() {
|
||||||
|
if (this.isLoadingCharacters || !this.hasMore) return;
|
||||||
|
this.isLoadingCharacters = true;
|
||||||
|
try {
|
||||||
|
const res = await getOriginalCharacters(this.id, this.page);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
const content = res.data.data?.content || [];
|
||||||
|
this.characters = this.characters.concat(content);
|
||||||
|
this.hasMore = content.length > 0;
|
||||||
|
this.page++;
|
||||||
|
} else {
|
||||||
|
this.notifyError('캐릭터 목록 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('캐릭터 목록 조회 실패');
|
||||||
|
} finally {
|
||||||
|
this.isLoadingCharacters = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScroll() {
|
||||||
|
const scrollPosition = window.innerHeight + window.scrollY;
|
||||||
|
const documentHeight = document.documentElement.offsetHeight;
|
||||||
|
if (scrollPosition >= documentHeight - 200 && !this.isLoadingCharacters && this.hasMore) {
|
||||||
|
this.loadCharacters();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openAssignDialog() {
|
||||||
|
this.assignDialog = true;
|
||||||
|
this.searchKeyword = '';
|
||||||
|
this.searchResults = [];
|
||||||
|
this.selectedToAssign = [];
|
||||||
|
},
|
||||||
|
onSearchInput() {
|
||||||
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||||
|
this.debounceTimer = setTimeout(this.search, 300);
|
||||||
|
},
|
||||||
|
async search() {
|
||||||
|
if (!this.searchKeyword || !this.searchKeyword.trim()) {
|
||||||
|
this.searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.searchLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await searchCharacters(this.searchKeyword.trim(), 1, 20);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.searchResults = res.data.data?.content || [];
|
||||||
|
} else {
|
||||||
|
this.notifyError('검색 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('검색 실패');
|
||||||
|
} finally {
|
||||||
|
this.searchLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async assign() {
|
||||||
|
if (this.selectedToAssign.length === 0) return;
|
||||||
|
try {
|
||||||
|
const ids = this.selectedToAssign.map(x => x.id);
|
||||||
|
const res = await assignCharactersToOriginal(this.id, ids);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.notifySuccess('연결되었습니다.');
|
||||||
|
this.assignDialog = false;
|
||||||
|
// 목록 초기화 후 재조회
|
||||||
|
this.characters = [];
|
||||||
|
this.page = 1;
|
||||||
|
this.hasMore = true;
|
||||||
|
this.loadCharacters();
|
||||||
|
} else {
|
||||||
|
this.notifyError('연결 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('연결 실패');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async unassign(ids) {
|
||||||
|
if (!ids || ids.length === 0) return;
|
||||||
|
try {
|
||||||
|
const res = await unassignCharactersFromOriginal(this.id, ids);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.notifySuccess('해제되었습니다.');
|
||||||
|
this.characters = this.characters.filter(c => !ids.includes(c.id));
|
||||||
|
} else {
|
||||||
|
this.notifyError('해제 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('해제 실패');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-no-wrap { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
</style>
|
505
src/views/Chat/OriginalForm.vue
Normal file
505
src/views/Chat/OriginalForm.vue
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
@click="goBack"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-arrow-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>{{ isEdit ? '원작 수정' : '원작 등록' }}</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-card class="pa-4">
|
||||||
|
<v-form
|
||||||
|
ref="form"
|
||||||
|
v-model="isFormValid"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-file-input
|
||||||
|
v-model="form.image"
|
||||||
|
label="이미지"
|
||||||
|
accept="image/*"
|
||||||
|
prepend-icon="mdi-camera"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:class="{ 'required-asterisk': !isEdit }"
|
||||||
|
:rules="imageRules"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
v-if="previewImage || form.imageUrl"
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<v-avatar size="150">
|
||||||
|
<v-img
|
||||||
|
:src="previewImage || form.imageUrl"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
</v-avatar>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.title"
|
||||||
|
label="제목"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="[v=>!!v||'제목은 필수입니다']"
|
||||||
|
class="required-asterisk"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.contentType"
|
||||||
|
label="콘텐츠 타입"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="contentTypeRules"
|
||||||
|
:class="{ 'required-asterisk': !isEdit }"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.category"
|
||||||
|
label="카테고리(장르)"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="categoryRules"
|
||||||
|
:class="{ 'required-asterisk': !isEdit }"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- 추가 메타 정보 (요구 순서: 글/그림, 제작사, 원천원작, 원천 원작 링크) -->
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.writer"
|
||||||
|
label="글/그림"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.studio"
|
||||||
|
label="제작사"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.originalWork"
|
||||||
|
label="원천 원작"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.originalLink"
|
||||||
|
label="원천 원작 링크"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:rules="originalLinkRules"
|
||||||
|
:class="{ 'required-asterisk': !isEdit }"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="12"
|
||||||
|
>
|
||||||
|
<v-switch
|
||||||
|
v-model="form.isAdult"
|
||||||
|
label="19금 여부"
|
||||||
|
inset
|
||||||
|
/>
|
||||||
|
</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="11">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newOriginalLink"
|
||||||
|
label="원작 링크 추가"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
@keyup.enter="addOriginalLink"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="1">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
class="mt-1"
|
||||||
|
block
|
||||||
|
:disabled="!newOriginalLink || !newOriginalLink.trim()"
|
||||||
|
@click="addOriginalLink"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-card
|
||||||
|
outlined
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
<v-list v-if="form.originalLinks && form.originalLinks.length > 0">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(link, idx) in form.originalLinks"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-list-item-title class="text-truncate">
|
||||||
|
{{ link }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item-content>
|
||||||
|
<v-list-item-action>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
@click="removeOriginalLink(idx)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-list-item-action>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-card-text
|
||||||
|
v-else
|
||||||
|
class="grey--text"
|
||||||
|
>
|
||||||
|
추가된 원작 링크가 없습니다.
|
||||||
|
</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-combobox
|
||||||
|
v-model="form.tags"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
small-chips
|
||||||
|
deletable-chips
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
label="태그를 입력 후 엔터로 추가"
|
||||||
|
@keydown.space.prevent="onTagSpace"
|
||||||
|
>
|
||||||
|
<template v-slot:selection="{ attrs, item, select, selected }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="attrs"
|
||||||
|
:input-value="selected"
|
||||||
|
close
|
||||||
|
@click="select"
|
||||||
|
@click:close="removeTag(item)"
|
||||||
|
>
|
||||||
|
{{ item }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-combobox>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-textarea
|
||||||
|
v-model="form.description"
|
||||||
|
label="작품 소개"
|
||||||
|
outlined
|
||||||
|
rows="4"
|
||||||
|
:rules="descriptionRules"
|
||||||
|
:class="{ 'required-asterisk': !isEdit }"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
{{ isEdit ? '수정' : '등록' }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-form>
|
||||||
|
</v-card>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createOriginal, updateOriginal, getOriginal } from '@/api/original'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OriginalForm',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isEdit: false,
|
||||||
|
isFormValid: false,
|
||||||
|
previewImage: null,
|
||||||
|
newOriginalLink: '',
|
||||||
|
form: {
|
||||||
|
id: null,
|
||||||
|
image: null,
|
||||||
|
imageUrl: null,
|
||||||
|
title: '',
|
||||||
|
contentType: '',
|
||||||
|
category: '',
|
||||||
|
isAdult: false,
|
||||||
|
description: '',
|
||||||
|
originalLink: '', // 원천 원작 링크(파라미터명 유지)
|
||||||
|
originalWork: '',
|
||||||
|
writer: '',
|
||||||
|
studio: '',
|
||||||
|
originalLinks: [], // 추가 원작 링크들
|
||||||
|
tags: []
|
||||||
|
},
|
||||||
|
originalInitial: null,
|
||||||
|
imageRules: [v => (this.isEdit ? true : (!!v || '이미지를 선택하세요'))],
|
||||||
|
contentTypeRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '콘텐츠 타입은 필수입니다'))],
|
||||||
|
categoryRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '카테고리는 필수입니다'))],
|
||||||
|
originalLinkRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '원천 원작 링크는 필수입니다'))],
|
||||||
|
descriptionRules: [v => (this.isEdit ? true : (!!(v && v.toString().trim()) || '작품 소개는 필수입니다'))]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
imageChanged() {
|
||||||
|
return !!this.form.image;
|
||||||
|
},
|
||||||
|
hasNonImageChanges() {
|
||||||
|
if (!this.isEdit || !this.originalInitial) return false;
|
||||||
|
const fields = ['title', 'contentType', 'category', 'isAdult', 'description', 'originalLink', 'originalWork', 'writer', 'studio'];
|
||||||
|
const basicChanged = fields.some(f => this.form[f] !== this.originalInitial[f]);
|
||||||
|
const arraysChanged = !this.arraysEqual(this.form.originalLinks, this.originalInitial.originalLinks)
|
||||||
|
|| !this.arraysEqual(this.form.tags, this.originalInitial.tags);
|
||||||
|
return basicChanged || arraysChanged;
|
||||||
|
},
|
||||||
|
hasEditChanges() {
|
||||||
|
return this.imageChanged || this.hasNonImageChanges;
|
||||||
|
},
|
||||||
|
canSubmit() {
|
||||||
|
if (this.isEdit) return this.hasEditChanges && !!(this.form.title && this.form.title.toString().trim());
|
||||||
|
const required = [this.form.image, this.form.title, this.form.contentType, this.form.category, this.form.originalLink, this.form.description];
|
||||||
|
return required.every(v => !!(v && (v.toString ? v.toString().trim() : v)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'form.image': {
|
||||||
|
handler(newImage) {
|
||||||
|
if (newImage) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => { this.previewImage = e.target.result }
|
||||||
|
reader.readAsDataURL(newImage)
|
||||||
|
} else {
|
||||||
|
this.previewImage = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
if (this.$route.query.id) {
|
||||||
|
this.isEdit = true;
|
||||||
|
this.load(this.$route.query.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) { this.$dialog.notify.error(message) },
|
||||||
|
notifySuccess(message) { this.$dialog.notify.success(message) },
|
||||||
|
goBack() { this.$router.push('/original-work') },
|
||||||
|
arraysEqual(a, b) {
|
||||||
|
const arrA = Array.isArray(a) ? a : [];
|
||||||
|
const arrB = Array.isArray(b) ? b : [];
|
||||||
|
if (arrA.length !== arrB.length) return false;
|
||||||
|
for (let i = 0; i < arrA.length; i++) {
|
||||||
|
if (arrA[i] !== arrB[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
addOriginalLink() {
|
||||||
|
if (!this.newOriginalLink || !this.newOriginalLink.trim()) return;
|
||||||
|
const val = this.newOriginalLink.trim();
|
||||||
|
if (!this.form.originalLinks) this.form.originalLinks = [];
|
||||||
|
if (!this.form.originalLinks.includes(val)) {
|
||||||
|
this.form.originalLinks.push(val);
|
||||||
|
}
|
||||||
|
this.newOriginalLink = '';
|
||||||
|
},
|
||||||
|
removeOriginalLink(index) {
|
||||||
|
if (!this.form.originalLinks) return;
|
||||||
|
this.form.originalLinks.splice(index, 1);
|
||||||
|
},
|
||||||
|
onTagSpace() {
|
||||||
|
// CharacterForm의 태그 방식과 유사: 마지막 항목을 공백 기준으로 확정
|
||||||
|
if (!Array.isArray(this.form.tags)) this.form.tags = [];
|
||||||
|
const last = this.form.tags[this.form.tags.length - 1];
|
||||||
|
if (typeof last === 'string' && last.trim()) {
|
||||||
|
let processed = last.trim().replace(/\s+/g, '');
|
||||||
|
if (processed.length > 50) processed = processed.substring(0, 50);
|
||||||
|
this.form.tags.splice(this.form.tags.length - 1, 1, processed);
|
||||||
|
this.$nextTick(() => this.form.tags.push(''));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeTag(item) {
|
||||||
|
if (!Array.isArray(this.form.tags)) return;
|
||||||
|
const idx = this.form.tags.indexOf(item);
|
||||||
|
if (idx >= 0) this.form.tags.splice(idx, 1);
|
||||||
|
},
|
||||||
|
async load(id) {
|
||||||
|
try {
|
||||||
|
const res = await getOriginal(id);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
const d = res.data.data;
|
||||||
|
this.form = {
|
||||||
|
id: d.id,
|
||||||
|
image: null,
|
||||||
|
imageUrl: d.imageUrl,
|
||||||
|
title: d.title || '',
|
||||||
|
contentType: d.contentType || '',
|
||||||
|
category: d.category || '',
|
||||||
|
isAdult: !!d.isAdult,
|
||||||
|
description: d.description || '',
|
||||||
|
originalLink: d.originalLink || '',
|
||||||
|
originalWork: d.originalWork || '',
|
||||||
|
writer: d.writer || '',
|
||||||
|
studio: d.studio || '',
|
||||||
|
originalLinks: Array.isArray(d.originalLinks) ? d.originalLinks.slice() : [],
|
||||||
|
tags: Array.isArray(d.tags) ? d.tags.slice() : []
|
||||||
|
}
|
||||||
|
this.originalInitial = {
|
||||||
|
id: d.id,
|
||||||
|
imageUrl: d.imageUrl,
|
||||||
|
title: d.title || '',
|
||||||
|
contentType: d.contentType || '',
|
||||||
|
category: d.category || '',
|
||||||
|
isAdult: !!d.isAdult,
|
||||||
|
description: d.description || '',
|
||||||
|
originalLink: d.originalLink || '',
|
||||||
|
originalWork: d.originalWork || '',
|
||||||
|
writer: d.writer || '',
|
||||||
|
studio: d.studio || '',
|
||||||
|
originalLinks: Array.isArray(d.originalLinks) ? d.originalLinks.slice() : [],
|
||||||
|
tags: Array.isArray(d.tags) ? d.tags.slice() : []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.notifyError('상세 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('상세 조회 실패');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onSubmit() {
|
||||||
|
try {
|
||||||
|
const isValid = this.$refs.form ? this.$refs.form.validate() : true;
|
||||||
|
if (!isValid) {
|
||||||
|
this.notifyError(this.isEdit ? '입력을 확인해주세요.' : '필수 항목을 모두 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isEdit) {
|
||||||
|
const fields = ['title', 'contentType', 'category', 'isAdult', 'description', 'originalLink', 'originalWork', 'writer', 'studio'];
|
||||||
|
const patch = { id: this.form.id };
|
||||||
|
if (this.originalInitial) {
|
||||||
|
fields.forEach(f => {
|
||||||
|
if (this.form[f] !== this.originalInitial[f]) {
|
||||||
|
patch[f] = this.form[f];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!this.arraysEqual(this.form.originalLinks, this.originalInitial.originalLinks)) {
|
||||||
|
patch.originalLinks = this.form.originalLinks;
|
||||||
|
}
|
||||||
|
if (!this.arraysEqual(this.form.tags, this.originalInitial.tags)) {
|
||||||
|
patch.tags = this.form.tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const image = this.form.image || null;
|
||||||
|
if (Object.keys(patch).length === 1 && !image) {
|
||||||
|
this.notifyError('변경된 내용이 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await updateOriginal(patch, image);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.notifySuccess('수정되었습니다.');
|
||||||
|
this.$router.push('/original-work');
|
||||||
|
} else {
|
||||||
|
this.notifyError('수정 실패');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await createOriginal(this.form);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.notifySuccess('등록되었습니다.');
|
||||||
|
this.$router.push('/original-work');
|
||||||
|
} else {
|
||||||
|
this.notifyError('등록 실패');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError(this.isEdit ? '수정 실패' : '등록 실패');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.required-asterisk >>> .v-label::after { content: ' *'; color: #ff5252; }
|
||||||
|
</style>
|
205
src/views/Chat/OriginalList.vue
Normal file
205
src/views/Chat/OriginalList.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-toolbar dark>
|
||||||
|
<v-spacer />
|
||||||
|
<v-toolbar-title>원작 리스트</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
dark
|
||||||
|
@click="goToCreate"
|
||||||
|
>
|
||||||
|
원작 등록
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-container>
|
||||||
|
<v-row v-if="isLoading && originals.length === 0">
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
size="64"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
v-for="item in originals"
|
||||||
|
:key="item.id"
|
||||||
|
cols="12"
|
||||||
|
sm="6"
|
||||||
|
md="4"
|
||||||
|
lg="3"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
class="mx-auto"
|
||||||
|
max-width="344"
|
||||||
|
style="cursor:pointer;"
|
||||||
|
@click="openDetail(item)"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
:src="item.imageUrl"
|
||||||
|
height="200"
|
||||||
|
contain
|
||||||
|
/>
|
||||||
|
<v-card-title class="text-no-wrap">
|
||||||
|
{{ item.title }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="primary"
|
||||||
|
@click.stop="editOriginal(item)"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="error"
|
||||||
|
@click.stop="confirmDelete(item)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row v-if="!isLoading && originals.length === 0">
|
||||||
|
<v-col class="text-center">
|
||||||
|
데이터가 없습니다.
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row v-if="isLoading && originals.length > 0">
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
|
||||||
|
<v-dialog
|
||||||
|
v-model="deleteDialog"
|
||||||
|
max-width="400"
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="headline">
|
||||||
|
삭제 확인
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>정말 삭제하시겠습니까?</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
@click="deleteDialog = false"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
text
|
||||||
|
@click="deleteItem"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getOriginalList, deleteOriginal } from '@/api/original'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'OriginalList',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isLoading: false,
|
||||||
|
originals: [],
|
||||||
|
page: 1,
|
||||||
|
hasMore: true,
|
||||||
|
deleteDialog: false,
|
||||||
|
selected: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadMore();
|
||||||
|
window.addEventListener('scroll', this.handleScroll);
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
window.removeEventListener('scroll', this.handleScroll);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
notifyError(message) { this.$dialog.notify.error(message) },
|
||||||
|
notifySuccess(message) { this.$dialog.notify.success(message) },
|
||||||
|
async loadMore() {
|
||||||
|
if (this.isLoading || !this.hasMore) return;
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await getOriginalList(this.page);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
const content = res.data.data?.content || [];
|
||||||
|
this.originals = this.originals.concat(content);
|
||||||
|
this.hasMore = content.length > 0;
|
||||||
|
this.page++;
|
||||||
|
} else {
|
||||||
|
this.notifyError('원작 목록 조회 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('원작 목록 조회 실패');
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleScroll() {
|
||||||
|
const scrollPosition = window.innerHeight + window.scrollY;
|
||||||
|
const documentHeight = document.documentElement.offsetHeight;
|
||||||
|
if (scrollPosition >= documentHeight - 200 && !this.isLoading && this.hasMore) {
|
||||||
|
this.loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToCreate() {
|
||||||
|
this.$router.push('/original-work/form');
|
||||||
|
},
|
||||||
|
editOriginal(item) {
|
||||||
|
this.$router.push({ path: '/original-work/form', query: { id: item.id } });
|
||||||
|
},
|
||||||
|
openDetail(item) {
|
||||||
|
this.$router.push({ path: '/original-work/detail', query: { id: item.id } });
|
||||||
|
},
|
||||||
|
confirmDelete(item) {
|
||||||
|
this.selected = item;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async deleteItem() {
|
||||||
|
if (!this.selected) return;
|
||||||
|
try {
|
||||||
|
const res = await deleteOriginal(this.selected.id);
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.notifySuccess('삭제되었습니다.');
|
||||||
|
this.originals = this.originals.filter(x => x.id !== this.selected.id);
|
||||||
|
} else {
|
||||||
|
this.notifyError('삭제 실패');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('삭제 실패');
|
||||||
|
} finally {
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-no-wrap { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
</style>
|
@@ -53,6 +53,24 @@
|
|||||||
<template v-slot:item.communitySettlementRatio="{ item }">
|
<template v-slot:item.communitySettlementRatio="{ item }">
|
||||||
{{ item.communitySettlementRatio }}%
|
{{ item.communitySettlementRatio }}%
|
||||||
</template>
|
</template>
|
||||||
|
<template v-slot:item.actions="{ item }">
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="primary"
|
||||||
|
text
|
||||||
|
@click="openEdit(item)"
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
color="red"
|
||||||
|
text
|
||||||
|
@click="confirmDelete(item)"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -73,13 +91,20 @@
|
|||||||
persistent
|
persistent
|
||||||
>
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>크리에이터 정산비율</v-card-title>
|
<v-card-title>{{ is_edit ? '크리에이터 정산비율 수정' : '크리에이터 정산비율' }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text v-show="!is_edit">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="creator_settlement_ratio.creator_id"
|
v-model="creator_settlement_ratio.creator_id"
|
||||||
label="크리에이터 번호"
|
label="크리에이터 번호"
|
||||||
/>
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
<v-card-text v-show="is_edit">
|
||||||
|
<v-text-field
|
||||||
|
v-model="creator_settlement_ratio.nickname"
|
||||||
|
disabled
|
||||||
|
label="크리에이터 닉네임"
|
||||||
|
/>
|
||||||
|
</v-card-text>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="creator_settlement_ratio.subsidy"
|
v-model="creator_settlement_ratio.subsidy"
|
||||||
@@ -118,7 +143,7 @@
|
|||||||
text
|
text
|
||||||
@click="validate"
|
@click="validate"
|
||||||
>
|
>
|
||||||
등록하기
|
{{ is_edit ? '수정하기' : '등록하기' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -142,6 +167,8 @@ export default {
|
|||||||
items: [],
|
items: [],
|
||||||
creator_settlement_ratio: {},
|
creator_settlement_ratio: {},
|
||||||
show_write_dialog: false,
|
show_write_dialog: false,
|
||||||
|
is_edit: false,
|
||||||
|
editing_item_id: null,
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
text: '닉네임',
|
text: '닉네임',
|
||||||
@@ -173,6 +200,12 @@ export default {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
value: 'communitySettlementRatio',
|
value: 'communitySettlementRatio',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '관리',
|
||||||
|
align: 'center',
|
||||||
|
sortable: false,
|
||||||
|
value: 'actions',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -191,11 +224,16 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
showWriteDialog() {
|
showWriteDialog() {
|
||||||
|
this.is_edit = false
|
||||||
|
this.editing_item_id = null
|
||||||
|
this.creator_settlement_ratio = {}
|
||||||
this.show_write_dialog = true
|
this.show_write_dialog = true
|
||||||
},
|
},
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
this.creator_settlement_ratio = {}
|
this.creator_settlement_ratio = {}
|
||||||
|
this.is_edit = false
|
||||||
|
this.editing_item_id = null
|
||||||
this.show_write_dialog = false
|
this.show_write_dialog = false
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -225,7 +263,11 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.is_edit) {
|
||||||
|
this.updateCreatorSettlementRatio();
|
||||||
|
} else {
|
||||||
this.createCreatorSettlementRatio();
|
this.createCreatorSettlementRatio();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCreatorSettlementRatio() {
|
async createCreatorSettlementRatio() {
|
||||||
@@ -253,6 +295,71 @@ export default {
|
|||||||
this.is_loading = false
|
this.is_loading = false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateCreatorSettlementRatio() {
|
||||||
|
if (this.is_loading) return;
|
||||||
|
this.is_loading = true
|
||||||
|
try {
|
||||||
|
// 수정은 생성과 동일한 파라미터를 전송 (memberId 기준)
|
||||||
|
const payload = { ...this.creator_settlement_ratio }
|
||||||
|
const res = await api.updateCreatorSettlementRatio(payload)
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.cancel()
|
||||||
|
this.notifySuccess(res.data.message || '수정되었습니다.')
|
||||||
|
this.items = []
|
||||||
|
await this.getSettlementRatio()
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openEdit(item) {
|
||||||
|
this.is_edit = true
|
||||||
|
this.editing_item_id = null
|
||||||
|
this.creator_settlement_ratio = {
|
||||||
|
creator_id: item.memberId,
|
||||||
|
nickname: item.nickname,
|
||||||
|
subsidy: item.subsidy,
|
||||||
|
liveSettlementRatio: item.liveSettlementRatio,
|
||||||
|
contentSettlementRatio: item.contentSettlementRatio,
|
||||||
|
communitySettlementRatio: item.communitySettlementRatio,
|
||||||
|
}
|
||||||
|
this.show_write_dialog = true
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmDelete(item) {
|
||||||
|
try {
|
||||||
|
const ok = await this.$dialog.confirm({ text: '삭제하시겠습니까?', title: '확인', actions: { false: '취소', true: '삭제' } })
|
||||||
|
if (!ok) return
|
||||||
|
} catch (e) {
|
||||||
|
// 일부 구현체는 confirm이 boolean이 아닌 경우가 있음
|
||||||
|
}
|
||||||
|
this.deleteCreatorSettlementRatio(item)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCreatorSettlementRatio(item) {
|
||||||
|
if (this.is_loading) return;
|
||||||
|
this.is_loading = true
|
||||||
|
try {
|
||||||
|
const memberId = item.memberId
|
||||||
|
const res = await api.deleteCreatorSettlementRatio(memberId)
|
||||||
|
if (res.status === 200 && res.data.success === true) {
|
||||||
|
this.notifySuccess(res.data.message || '삭제되었습니다.')
|
||||||
|
this.items = this.items.filter(x => (x.memberId) !== memberId)
|
||||||
|
} else {
|
||||||
|
this.notifyError(res.data.message || '알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.')
|
||||||
|
} finally {
|
||||||
|
this.is_loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async getSettlementRatio() {
|
async getSettlementRatio() {
|
||||||
this.is_loading = true
|
this.is_loading = true
|
||||||
|
|
||||||
@@ -279,10 +386,6 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async next() {
|
async next() {
|
||||||
if (this.search_word.length < 2) {
|
|
||||||
this.search_word = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.getSettlementRatio()
|
await this.getSettlementRatio()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user