캐릭터 챗봇 #74

Merged
klaus merged 33 commits from test into main 2025-09-10 06:26:03 +00:00
12 changed files with 4556 additions and 5 deletions

3
.gitignore vendored
View File

@ -218,4 +218,7 @@ $RECYCLE.BIN/
# Windows shortcuts # Windows shortcuts
*.lnk *.lnk
.kiro/
.junie/
# End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows # End of https://www.toptal.com/developers/gitignore/api/webstorm,visualstudiocode,vuejs,macos,windows

277
src/api/character.js Normal file
View File

@ -0,0 +1,277 @@
import Vue from 'vue';
// 캐릭터 리스트
async function getCharacterList(page = 1, size = 20) {
return Vue.axios.get('/admin/chat/character/list', {
params: { page: page - 1, size }
})
}
// 캐릭터 검색
async function searchCharacters(searchTerm, page = 1, size = 20) {
return Vue.axios.get('/admin/chat/banner/search-character', {
params: { searchTerm, page: page - 1, size }
})
}
// 캐릭터 상세 조회
async function getCharacter(id) {
return Vue.axios.get(`/admin/chat/character/${id}`)
}
// 내부 헬퍼: 빈 문자열을 null로 변환
function toNullIfBlank(value) {
if (typeof value === 'string') {
return value.trim() === '' ? null : value;
}
return value === '' ? null : value;
}
// 캐릭터 등록
async function createCharacter(characterData) {
const formData = new FormData()
// 이미지만 FormData에 추가
if (characterData.image) formData.append('image', characterData.image)
// 나머지 데이터는 JSON 문자열로 변환하여 request 필드에 추가
const requestData = {
name: toNullIfBlank(characterData.name),
systemPrompt: toNullIfBlank(characterData.systemPrompt),
description: toNullIfBlank(characterData.description),
age: toNullIfBlank(characterData.age),
gender: toNullIfBlank(characterData.gender),
mbti: toNullIfBlank(characterData.mbti),
characterType: toNullIfBlank(characterData.type),
originalTitle: toNullIfBlank(characterData.originalTitle),
originalLink: toNullIfBlank(characterData.originalLink),
speechPattern: toNullIfBlank(characterData.speechPattern),
speechStyle: toNullIfBlank(characterData.speechStyle),
appearance: toNullIfBlank(characterData.appearance),
tags: characterData.tags || [],
hobbies: characterData.hobbies || [],
values: characterData.values || [],
goals: characterData.goals || [],
relationships: characterData.relationships || [],
personalities: characterData.personalities || [],
backgrounds: characterData.backgrounds || [],
memories: characterData.memories || []
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.post('/admin/chat/character/register', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 수정
async function updateCharacter(characterData, image = null) {
const formData = new FormData()
// 이미지가 있는 경우에만 FormData에 추가
if (image) formData.append('image', image)
// 변경된 데이터만 JSON 문자열로 변환하여 request 필드에 추가 ('' -> null 변환)
// characterData는 이미 변경된 필드만 포함하고 있음
const processed = {}
Object.keys(characterData).forEach(key => {
const value = characterData[key]
if (typeof value === 'string' || value === '') {
processed[key] = toNullIfBlank(value)
} else {
processed[key] = value
}
})
formData.append('request', JSON.stringify(processed))
return Vue.axios.put(`/admin/chat/character/update`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 배너 리스트 조회
async function getCharacterBannerList(page = 1, size = 20) {
return Vue.axios.get('/admin/chat/banner/list', {
params: { page: page - 1, size }
})
}
// 캐릭터 배너 등록
async function createCharacterBanner(bannerData) {
const formData = new FormData()
// 이미지 FormData에 추가
if (bannerData.image) formData.append('image', bannerData.image)
// 캐릭터 ID를 JSON 문자열로 변환하여 request 필드에 추가
const requestData = {
characterId: bannerData.characterId
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.post('/admin/chat/banner/register', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 배너 수정
async function updateCharacterBanner(bannerData) {
const formData = new FormData()
// 이미지가 있는 경우에만 FormData에 추가
if (bannerData.image) formData.append('image', bannerData.image)
// 캐릭터 ID와 배너 ID를 JSON 문자열로 변환하여 request 필드에 추가
const requestData = {
characterId: bannerData.characterId,
bannerId: bannerData.bannerId
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.put('/admin/chat/banner/update', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 캐릭터 배너 삭제
async function deleteCharacterBanner(bannerId) {
return Vue.axios.delete(`/admin/chat/banner/${bannerId}`)
}
// 캐릭터 배너 순서 변경
async function updateCharacterBannerOrder(bannerIds) {
return Vue.axios.put('/admin/chat/banner/orders', {ids: bannerIds})
}
// 캐릭터 이미지 리스트
async function getCharacterImageList(characterId, page = 1, size = 20) {
return Vue.axios.get('/admin/chat/character/image/list', {
params: { characterId, page: page - 1, size }
})
}
// 캐릭터 이미지 상세
async function getCharacterImage(imageId) {
return Vue.axios.get(`/admin/chat/character/image/${imageId}`)
}
// 캐릭터 이미지 등록
async function createCharacterImage(imageData) {
const formData = new FormData()
if (imageData.image) formData.append('image', imageData.image)
const requestData = {
characterId: imageData.characterId,
imagePriceCan: imageData.imagePriceCan,
messagePriceCan: imageData.messagePriceCan,
isAdult: imageData.isAdult,
triggers: imageData.triggers || []
}
formData.append('request', JSON.stringify(requestData))
return Vue.axios.post('/admin/chat/character/image/register', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 캐릭터 이미지 수정 (트리거만 수정)
async function updateCharacterImage(imageData) {
const imageId = imageData.imageId
const payload = { triggers: imageData.triggers || [] }
return Vue.axios.put(`/admin/chat/character/image/${imageId}/triggers`, payload)
}
// 캐릭터 이미지 삭제
async function deleteCharacterImage(imageId) {
return Vue.axios.delete(`/admin/chat/character/image/${imageId}`)
}
// 캐릭터 이미지 순서 변경
async function updateCharacterImageOrder(characterId, imageIds) {
return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: imageIds })
}
// 캐릭터 큐레이션 목록
async function getCharacterCurationList() {
return Vue.axios.get('/admin/chat/character/curation/list')
}
// 캐릭터 큐레이션 등록
async function createCharacterCuration({ title, isAdult, isActive }) {
return Vue.axios.post('/admin/chat/character/curation/register', { title, isAdult, isActive })
}
// 캐릭터 큐레이션 수정
// payload: { id: Long, title?, isAdult?, isActive? }
async function updateCharacterCuration(payload) {
return Vue.axios.put('/admin/chat/character/curation/update', payload)
}
// 캐릭터 큐레이션 삭제
async function deleteCharacterCuration(curationId) {
return Vue.axios.delete(`/admin/chat/character/curation/${curationId}`)
}
// 캐릭터 큐레이션 정렬 순서 변경
async function updateCharacterCurationOrder(ids) {
return Vue.axios.put('/admin/chat/character/curation/reorder', { ids })
}
// 큐레이션에 캐릭터 등록 (다중 등록)
// characterIds: Array<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`)
}
export {
getCharacterList,
searchCharacters,
getCharacter,
createCharacter,
updateCharacter,
getCharacterBannerList,
createCharacterBanner,
updateCharacterBanner,
deleteCharacterBanner,
updateCharacterBannerOrder,
getCharacterImageList,
getCharacterImage,
createCharacterImage,
updateCharacterImage,
deleteCharacterImage,
updateCharacterImageOrder,
// Character Curation
getCharacterCurationList,
createCharacterCuration,
updateCharacterCuration,
deleteCharacterCuration,
updateCharacterCurationOrder,
addCharacterToCuration,
removeCharacterFromCuration,
updateCurationCharactersOrder,
getCharactersInCuration
}

View File

@ -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>
@ -95,6 +96,29 @@ export default {
let res = await api.getMenus(); let res = await api.getMenus();
if (res.status === 200 && res.data.success === true && res.data.data.length > 0) { if (res.status === 200 && res.data.success === true && res.data.data.length > 0) {
this.items = res.data.data this.items = res.data.data
//
this.items.push({
title: '캐릭터 챗봇',
route: null,
items: [
{
title: '배너 등록',
route: '/character/banner',
items: null
},
{
title: '캐릭터 리스트',
route: '/character',
items: null
},
{
title: '큐레이션',
route: '/character/curation',
items: null
}
]
})
} else { } else {
this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!")
this.logout(); this.logout();

View File

@ -255,6 +255,41 @@ const routes = [
name: 'MarketingAdStatisticsView', name: 'MarketingAdStatisticsView',
component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue') component: () => import(/* webpackChunkName: "marketing" */ '../views/Marketing/MarketingAdStatisticsView.vue')
}, },
{
path: '/character',
name: 'CharacterList',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterList.vue')
},
{
path: '/character/form',
name: 'CharacterForm',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterForm.vue')
},
{
path: '/character/banner',
name: 'CharacterBanner',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue')
},
{
path: '/character/images',
name: 'CharacterImageList',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageList.vue')
},
{
path: '/character/images/form',
name: 'CharacterImageForm',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue')
},
{
path: '/character/curation',
name: 'CharacterCuration',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCuration.vue')
},
{
path: '/character/curation/detail',
name: 'CharacterCurationDetail',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue')
},
] ]
}, },
{ {

View File

@ -60,7 +60,7 @@
<v-card-text> <v-card-text>
지급할 : {{ can }} 지급할 : {{ can }}
</v-card-text> </v-card-text>
<v-card-actions v-show="!isLoading"> <v-card-actions v-show="!is_loading">
<v-spacer /> <v-spacer />
<v-btn <v-btn
color="blue darken-1" color="blue darken-1"
@ -95,7 +95,7 @@ export default {
data() { data() {
return { return {
show_confirm: false, show_confirm: false,
isLoading: false, is_loading: false,
account_id: '', account_id: '',
method: '', method: '',
can: '' can: ''
@ -124,7 +124,7 @@ export default {
return this.notifyError('캔은 숫자만 넣을 수 있습니다.') return this.notifyError('캔은 숫자만 넣을 수 있습니다.')
} }
if (!this.isLoading) { if (!this.is_loading) {
this.show_confirm = true this.show_confirm = true
} }
}, },
@ -134,8 +134,8 @@ export default {
}, },
async submit() { async submit() {
if (!this.isLoading) { if (!this.is_loading) {
this.isLoading = true this.is_loading = true
try { try {
this.show_confirm = false this.show_confirm = false

View File

@ -0,0 +1,583 @@
<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>
<v-col cols="4">
<v-btn
color="primary"
dark
@click="showAddDialog"
>
배너 추가
</v-btn>
</v-col>
<v-spacer />
</v-row>
<!-- 로딩 표시 -->
<v-row v-if="isLoading && banners.length === 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
</v-col>
</v-row>
<!-- 배너 그리드 -->
<v-row>
<draggable
v-model="banners"
class="row"
style="width: 100%"
:options="{ animation: 150 }"
@end="onDragEnd"
>
<v-col
v-for="banner in banners"
:key="banner.id"
cols="12"
sm="6"
md="4"
lg="3"
class="banner-item"
>
<v-card
class="mx-auto"
max-width="300"
>
<v-img
:src="banner.imageUrl"
height="200"
contain
/>
<v-card-text class="text-center">
<div>{{ banner.characterName }}</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
small
color="primary"
@click="showEditDialog(banner)"
>
수정
</v-btn>
<v-btn
small
color="error"
@click="confirmDelete(banner)"
>
삭제
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
</draggable>
</v-row>
<!-- 데이터가 없을 표시 -->
<v-row v-if="!isLoading && banners.length === 0">
<v-col class="text-center">
<p>등록된 배너가 없습니다.</p>
</v-col>
</v-row>
<!-- 무한 스크롤 로딩 -->
<v-row v-if="isLoading && banners.length > 0">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
/>
</v-col>
</v-row>
</v-container>
<!-- 배너 추가/수정 다이얼로그 -->
<v-dialog
v-model="showDialog"
max-width="600px"
persistent
>
<v-card>
<v-card-title>
<span class="headline">{{ isEdit ? '배너 수정' : '배너 추가' }}</span>
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12">
<v-file-input
v-model="bannerForm.image"
label="배너 이미지"
accept="image/*"
prepend-icon="mdi-camera"
show-size
truncate-length="15"
:rules="imageRules"
outlined
/>
</v-col>
</v-row>
<v-row v-if="previewImage || (isEdit && bannerForm.imageUrl)">
<v-col
cols="12"
class="text-center"
>
<v-img
:src="previewImage || bannerForm.imageUrl"
max-height="200"
contain
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="searchKeyword"
label="캐릭터 검색"
outlined
@keyup.enter="searchCharacter"
/>
</v-col>
</v-row>
<v-row v-if="searchResults.length > 0">
<v-col cols="12">
<v-list>
<v-list-item
v-for="character in searchResults"
:key="character.id"
@click="selectCharacter(character)"
>
<v-list-item-avatar>
<v-img :src="character.imageUrl" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{ character.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
</v-row>
<v-row v-if="searchPerformed && searchResults.length === 0">
<v-col cols="12">
<v-alert
type="info"
outlined
>
검색결과가 없습니다.
</v-alert>
</v-col>
</v-row>
<v-row v-if="selectedCharacter">
<v-col cols="12">
<v-alert
type="info"
outlined
>
<v-row align="center">
<v-col cols="auto">
<v-avatar size="50">
<v-img :src="selectedCharacter.imageUrl" />
</v-avatar>
</v-col>
<v-col>
<div class="font-weight-medium">
선택된 캐릭터: {{ selectedCharacter.name }}
</div>
</v-col>
</v-row>
</v-alert>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="closeDialog"
>
취소
</v-btn>
<v-btn
color="blue darken-1"
text
:disabled="!isFormValid || isSubmitting"
:loading="isSubmitting"
@click="saveBanner"
>
저장
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 삭제 확인 다이얼로그 -->
<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
color="blue darken-1"
text
@click="showDeleteDialog = false"
>
취소
</v-btn>
<v-btn
color="red darken-1"
text
:loading="isSubmitting"
@click="deleteBanner"
>
삭제
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import {
getCharacterBannerList,
createCharacterBanner,
updateCharacterBanner,
deleteCharacterBanner,
updateCharacterBannerOrder,
searchCharacters
} from '@/api/character';
import draggable from 'vuedraggable';
export default {
name: 'CharacterBanner',
components: {
draggable
},
data() {
return {
isLoading: false,
isSubmitting: false,
banners: [],
page: 1,
hasMoreItems: true,
showDialog: false,
showDeleteDialog: false,
isEdit: false,
selectedBanner: null,
selectedCharacter: null,
searchKeyword: '',
searchResults: [],
searchPerformed: false,
previewImage: null,
bannerForm: {
image: null,
imageUrl: '',
characterId: null,
bannerId: null
},
imageRules: [
v => !!v || this.isEdit || '이미지를 선택하세요'
]
};
},
computed: {
isFormValid() {
return (this.bannerForm.image || (this.isEdit && this.bannerForm.imageUrl)) && this.selectedCharacter;
}
},
watch: {
'bannerForm.image': {
handler(newImage) {
if (newImage) {
this.createImagePreview(newImage);
} else {
this.previewImage = null;
}
}
}
},
mounted() {
this.loadBanners();
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);
},
goBack() {
this.$router.push('/character');
},
async loadBanners() {
if (this.isLoading || !this.hasMoreItems) return;
this.isLoading = true;
try {
const response = await getCharacterBannerList(this.page);
if (response && response.status === 200 && response.data && response.data.success === true) {
const data = response.data.data;
const newBanners = data.content || [];
this.banners = [...this.banners, ...newBanners];
//
this.hasMoreItems = newBanners.length > 0;
this.page++;
} else {
this.notifyError('배너 목록을 불러오는데 실패했습니다.');
}
} catch (error) {
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.hasMoreItems) {
this.loadBanners();
}
},
showAddDialog() {
this.isEdit = false;
this.selectedCharacter = null;
this.bannerForm = {
image: null,
imageUrl: '',
characterId: null,
bannerId: null
};
this.previewImage = null;
this.searchKeyword = '';
this.searchResults = [];
this.searchPerformed = false;
this.showDialog = true;
},
showEditDialog(banner) {
this.isEdit = true;
this.selectedBanner = banner;
this.selectedCharacter = {
id: banner.characterId,
name: banner.characterName,
imageUrl: banner.characterImageUrl
};
this.bannerForm = {
image: null,
imageUrl: banner.imageUrl,
characterId: banner.characterId,
bannerId: banner.id
};
this.previewImage = null;
this.searchKeyword = '';
this.searchResults = [];
this.searchPerformed = false;
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
this.selectedCharacter = null;
this.bannerForm = {
image: null,
imageUrl: '',
characterId: null,
bannerId: null
};
this.previewImage = null;
this.searchKeyword = '';
this.searchResults = [];
this.searchPerformed = false;
},
confirmDelete(banner) {
this.selectedBanner = banner;
this.showDeleteDialog = true;
},
createImagePreview(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
this.previewImage = e.target.result;
};
reader.readAsDataURL(file);
},
async searchCharacter() {
if (!this.searchKeyword || this.searchKeyword.length < 2) {
this.notifyError('검색어를 2글자 이상 입력하세요.');
return;
}
try {
const response = await searchCharacters(this.searchKeyword);
if (response && response.status === 200 && response.data && response.data.success === true) {
const data = response.data.data;
this.searchResults = data.content || [];
this.searchPerformed = true;
}
} catch (error) {
console.error('캐릭터 검색 오류:', error);
this.notifyError('캐릭터 검색에 실패했습니다.');
}
},
selectCharacter(character) {
this.selectedCharacter = character;
this.bannerForm.characterId = character.id;
this.searchResults = [];
},
async saveBanner() {
if (!this.isFormValid || this.isSubmitting) return;
this.isSubmitting = true;
try {
if (this.isEdit) {
//
const response = await updateCharacterBanner({
image: this.bannerForm.image,
characterId: this.selectedCharacter.id,
bannerId: this.bannerForm.bannerId
});
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너가 수정되었습니다.');
} else {
this.notifyError('배너 수정을 실패했습니다.');
}
} else {
//
const response = await createCharacterBanner({
image: this.bannerForm.image,
characterId: this.selectedCharacter.id
});
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너가 추가되었습니다.');
//
this.closeDialog();
this.refreshBanners();
} else {
this.notifyError('배너 추가를 실패했습니다.');
}
}
} catch (error) {
console.error('배너 저장 오류:', error);
this.notifyError('배너 저장에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
},
async deleteBanner() {
if (!this.selectedBanner || this.isSubmitting) return;
this.isSubmitting = true;
try {
const response = await deleteCharacterBanner(this.selectedBanner.id);
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너가 삭제되었습니다.');
this.showDeleteDialog = false;
this.refreshBanners();
} else {
this.notifyError('배너 삭제에 실패했습니다.');
}
} catch (error) {
console.error('배너 삭제 오류:', error);
this.notifyError('배너 삭제에 실패했습니다.');
} finally {
this.isSubmitting = false;
}
},
refreshBanners() {
//
this.banners = [];
this.page = 1;
this.hasMoreItems = true;
this.loadBanners();
},
async onDragEnd() {
// API
try {
const bannerIds = this.banners.map(banner => banner.id);
const response = await updateCharacterBannerOrder(bannerIds);
if (response && response.status === 200 && response.data && response.data.success === true) {
this.notifySuccess('배너 순서가 변경되었습니다.');
} else {
this.notifyError('배너 순서 변경에 실패했습니다.');
}
} catch (error) {
console.error('배너 순서 변경 오류:', error);
this.notifyError('배너 순서 변경에 실패했습니다.');
//
this.refreshBanners();
}
}
}
};
</script>
<style scoped>
.banner-item {
transition: all 0.3s;
}
.banner-item:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
</style>

View 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>

View 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

View 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>

View 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>

View File

@ -0,0 +1,406 @@
<template>
<div>
<v-toolbar dark>
<v-spacer />
<v-toolbar-title>캐릭터 리스트</v-toolbar-title>
<v-spacer />
</v-toolbar>
<br>
<v-container>
<v-row>
<v-col cols="4">
<v-btn
color="primary"
dark
@click="showAddDialog"
>
캐릭터 추가
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
<v-simple-table class="elevation-10">
<template>
<thead>
<tr>
<th class="text-center">
ID
</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">
MBTI
</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 characters"
:key="item.id"
>
<td>{{ item.id }}</td>
<td align="center">
<v-img
max-width="100"
max-height="100"
:src="item.imageUrl"
class="rounded-circle"
/>
</td>
<td>{{ item.name }}</td>
<td>{{ item.gender || '-' }}</td>
<td>{{ item.age || '-' }}</td>
<td>
<v-btn
small
color="info"
@click="showDetailDialog(item, 'description')"
>
보기
</v-btn>
</td>
<td>{{ item.mbti || '-' }}</td>
<td>
<v-btn
small
color="info"
@click="showDetailDialog(item, 'speechPattern')"
>
보기
</v-btn>
</td>
<td>
<v-btn
small
color="info"
@click="showDetailDialog(item, 'speechStyle')"
>
보기
</v-btn>
</td>
<td>
<div v-if="item.tags && item.tags.length > 0">
<v-chip
v-for="(tag, index) in item.tags"
:key="index"
small
class="ma-1"
color="primary"
text-color="white"
>
{{ tag }}
</v-chip>
</div>
<span v-else>-</span>
</td>
<td>{{ item.createdAt }}</td>
<td>{{ item.updatedAt || '-' }}</td>
<td>
<v-row>
<v-col>
<v-btn
small
color="primary"
:disabled="is_loading"
@click="showEditDialog(item)"
>
수정
</v-btn>
</v-col>
<v-col>
<v-btn
small
color="info"
:disabled="is_loading"
@click="goToImageList(item)"
>
이미지
</v-btn>
</v-col>
<v-col>
<v-btn
small
color="error"
:disabled="is_loading"
@click="deleteConfirm(item)"
>
삭제
</v-btn>
</v-col>
</v-row>
</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="next"
/>
</v-col>
</v-row>
</v-container>
<!-- 삭제 확인 다이얼로그 -->
<v-dialog
v-model="show_delete_confirm_dialog"
max-width="400px"
persistent
>
<v-card>
<v-card-text />
<v-card-text>
"{{ selected_character.name }}"() 삭제하시겠습니까?
</v-card-text>
<v-card-actions v-show="!is_loading">
<v-spacer />
<v-btn
color="blue darken-1"
text
@click="closeDeleteDialog"
>
취소
</v-btn>
<v-btn
color="red darken-1"
text
@click="deleteCharacter"
>
확인
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 상세 내용 다이얼로그 -->
<v-dialog
v-model="show_detail_dialog"
max-width="600px"
>
<v-card>
<v-card-title>
{{ detail_title }}
</v-card-title>
<v-divider />
<v-card-text class="pt-4">
<div style="white-space: pre-wrap;">
{{ detail_content }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
text
@click="closeDetailDialog"
>
닫기
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { getCharacterList, updateCharacter } from '@/api/character'
export default {
name: "CharacterList",
data() {
return {
is_loading: false,
show_delete_confirm_dialog: false,
show_detail_dialog: false,
detail_type: '',
detail_content: '',
detail_title: '',
page: 1,
total_page: 0,
characters: [],
selected_character: {}
}
},
async created() {
await this.getCharacters()
},
methods: {
notifyError(message) {
this.$dialog.notify.error(message)
},
notifySuccess(message) {
this.$dialog.notify.success(message)
},
showDetailDialog(item, type) {
this.selected_character = item;
this.detail_type = type;
//
switch(type) {
case 'description':
this.detail_title = '캐릭터 설명';
this.detail_content = item.description || '내용이 없습니다.';
break;
case 'speechPattern':
this.detail_title = '말투';
this.detail_content = item.speechPattern || '내용이 없습니다.';
break;
case 'speechStyle':
this.detail_title = '대화 스타일';
this.detail_content = item.speechStyle || '내용이 없습니다.';
break;
default:
this.detail_title = '';
this.detail_content = '';
}
this.show_detail_dialog = true;
},
closeDetailDialog() {
this.show_detail_dialog = false;
this.detail_type = '';
this.detail_content = '';
this.detail_title = '';
},
showAddDialog() {
//
this.$router.push('/character/form');
},
goToImageList(item) {
this.$router.push({
path: '/character/images',
query: { characterId: item.id, name: item.name }
})
},
showEditDialog(item) {
// id
this.$router.push({
path: '/character/form',
query: { id: item.id }
});
},
deleteConfirm(item) {
this.selected_character = item
this.show_delete_confirm_dialog = true
},
closeDeleteDialog() {
this.show_delete_confirm_dialog = false
this.selected_character = {}
},
async deleteCharacter() {
if (this.is_loading) return;
this.is_loading = true
try {
// isActive false
const updateData = {
id: this.selected_character.id,
isActive: false
};
await updateCharacter(updateData);
this.closeDeleteDialog();
this.notifySuccess('삭제되었습니다.');
await this.getCharacters();
} catch (e) {
console.error('캐릭터 삭제 오류:', e);
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
} finally {
this.is_loading = false;
}
},
async next() {
await this.getCharacters()
},
async getCharacters() {
this.is_loading = true
try {
const response = await getCharacterList(this.page);
if (response && response.status === 200) {
if (response.data.success === true) {
const data = response.data.data;
this.characters = data.content || [];
const total_page = Math.ceil((data.totalCount || 0) / 20);
this.total_page = total_page <= 0 ? 1 : total_page;
} else {
this.notifyError('응답 데이터가 없습니다.');
}
} else {
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
}
} catch (e) {
console.error('캐릭터 목록 조회 오류:', e);
this.notifyError('알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.');
} finally {
this.is_loading = false;
}
},
}
}
</script>
<style scoped>
.v-data-table {
width: 100%;
}
</style>