Compare commits

..

18 Commits

Author SHA1 Message Date
Yu Sung
3c28367be9 feat(calculate-ratio): 정산 비율 수정/삭제 추가 2025-09-22 14:52:37 +09:00
Yu Sung
8f0958848d feat(calculate-ratio): 정산 비율 수정/삭제 추가 2025-09-22 14:35:37 +09:00
Yu Sung
a4cf43b88a feat(character-list): 캐릭터 리스트 페이지 검색 추가 2025-09-18 20:04:20 +09:00
Yu Sung
40c5a6593e feat(original): 원작
- 원천 원작, 원천 원작 링크, 글/그림 작가, 제작사, 태그 추가
2025-09-18 18:45:14 +09:00
Yu Sung
edab727c22 캐릭터 배너 - 이미지 변수 변경하여 이미지가 표시되지 않던 버그 수정 2025-09-15 15:19:54 +09:00
Yu Sung
00b12d0edb feat(original): 캐릭터 등록/수정
- 원작 등록/삭제 추가
2025-09-15 06:53:39 +09:00
Yu Sung
6507b025de feat(original): 원작
- 등록, 수정, 삭제
- 캐릭터 연결, 해제 기능 추가
2025-09-15 04:27:22 +09:00
Yu Sung
cd86973b60 fix(character): 캐릭터 등록 폼
- 코드 포맷팅 적용
2025-09-13 05:27:12 +09:00
Yu Sung
1e4dcffc17 feat(character-calculator): 캐릭터별 정산 추가 2025-09-13 05:25:35 +09:00
Yu Sung
5ee0fe6a60 fix(chat): 인물관계 삭제 후 수정 저장 시 서버 반영되지 않던 문제 수정
수정 모드에서 saveCharacter가 변경 필드만 전송하면서 relationships 배열이 제외되어
삭제/수정 사항이 서버에 반영되지 않는 문제가 있었습니다. 수정 시 항상
relationships를 포함해 서버와 동기화되도록 변경했습니다.

- CharacterForm.vue: update 시 changedData.relationships 항상 포함
2025-09-09 14:54:18 +09:00
Yu Sung
199049ab7c feat(chat): 캐릭터 폼에 JSON 내보내기/가져오기 기능 추가
- 툴바에 'JSON 다운로드/업로드' 버튼 추가
- buildSerializablePayload, exportToJson, onImportFileChange, applyImportedData 메서드 구현
- 이미지(image, imageUrl) 및 isActive는 직렬화/역직렬화에서 제외
- 업로드 시 버전 검증 및 길이/개수 제한, 중요도(1~10) 보정 적용
- 사용자 알림 메시지(성공/오류) 한글화
2025-09-06 00:58:00 +09:00
Yu Sung
bc8833483a fix(character-image): 캐릭터 이미지 등록/수정
- 트리거 단어 최소 개수 3개로 수정
2025-09-02 15:31:24 +09:00
Yu Sung
b94aa54365 캐릭터 챗봇 큐레이션 추가 2025-08-28 19:38:21 +09:00
Yu Sung
478ef2e7fe 말투/특징적 표현 1000자, 대화 스타일 1000자로 변경 2025-08-28 15:20:14 +09:00
Yu Sung
63ebe9708f feat(character-image): 캐릭터 이미지 관리(목록/등록/수정/삭제/정렬) 추가 2025-08-22 02:25:37 +09:00
Yu Sung
071502d869 fix(character-form): 저장 버튼 비활성 문제 수정 및 필수 라벨 * 표시
- 등록(create) 모드에서 필수값 충족 시 버튼 활성화되도록 유효성 처리 정비

- 필수 항목 라벨에 빨간색 * 표시

- 인물관계 입력 필드의 검증 규칙을 v-form 유효성에서 제외

- 인물관계 필드 힌트 문구 개선
2025-08-15 01:37:46 +09:00
Yu Sung
806af4aba0 fix(character-form): 인물 관계 입력 레이아웃 3단 구성 및 입력 방식 수정
- relationshipType, currentStatus를 v-text-field로 변경하고 길이 제한(<=10자) 및 필수 입력 검증 추가
- 인물 관계 입력을 3단 레이아웃으로 재구성 (1행: 상대방 이름+관계명, 2행: 관계 타입+현재 상태+중요도, 3행: 관계 설명)
- addRelationship 로직 보강: 각 필드 substring 보정, 중요도 1~10 범위 보정, 최대 개수(10개) 체크
- 저장 로직 비교 함수에서 relationships를 객체 배열 비교 대상에 포함하여 변경 감지 정확도 개선

왜: 기존 TextField 하나로 관계를 모두 입력해 가독성과 구조화가 어려웠고, 선택형 필드 요구사항이 변경되어 직접 입력하도록 수정 필요
무엇: UI/검증/데이터 처리 전반을 요구사항에 맞게 분리 및 보강
2025-08-13 17:30:42 +09:00
Yu Sung
e09f654aba fix(character-form): 수정 모드에서 변경 사항만 있으면 저장 버튼 활성화
- isSaveDisabled 로직을 등록/수정 모드로 분리
- 수정(edit) 모드에서는 필수값 유효성과 무관하게 변경 감지 시 버튼 활성화
- 등록(create) 모드에서는 기존대로 폼 유효성으로 활성화 판단
- saveCharacter에서도 등록 모드에서만 필수값 유효성 검사를 강제하도록 수정

관련 파일: src/views/Chat/CharacterForm.vue
2025-08-13 00:55:35 +09:00
17 changed files with 3802 additions and 145 deletions

View File

@@ -24,7 +24,7 @@ async function getCalculateCommunityPost(startDate, endDate, page, size) {
}
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) {
@@ -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 {
getCalculateLive,
getCalculateContent,
@@ -65,6 +80,8 @@ export {
getCalculateCommunityPost,
getSettlementRatio,
createCreatorSettlementRatio,
updateCreatorSettlementRatio,
deleteCreatorSettlementRatio,
getCalculateLiveByCreator,
getCalculateContentByCreator,
getCalculateCommunityByCreator

View File

@@ -7,13 +7,20 @@ async function getCharacterList(page = 1, size = 20) {
})
}
// 캐릭터 검색
// 캐릭터 검색 (배너용 기존 함수)
async function searchCharacters(searchTerm, page = 1, size = 20) {
return Vue.axios.get('/admin/chat/banner/search-character', {
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) {
return Vue.axios.get(`/admin/chat/character/${id}`)
@@ -43,8 +50,7 @@ async function createCharacter(characterData) {
gender: toNullIfBlank(characterData.gender),
mbti: toNullIfBlank(characterData.mbti),
characterType: toNullIfBlank(characterData.type),
originalTitle: toNullIfBlank(characterData.originalTitle),
originalLink: toNullIfBlank(characterData.originalLink),
originalWorkId: characterData.originalWorkId || null,
speechPattern: toNullIfBlank(characterData.speechPattern),
speechStyle: toNullIfBlank(characterData.speechStyle),
appearance: toNullIfBlank(characterData.appearance),
@@ -154,9 +160,111 @@ 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`)
}
// 캐릭터별 정산 목록
// 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 {
getCharacterList,
searchCharacters,
searchCharacterList,
getCharacter,
createCharacter,
updateCharacter,
@@ -164,5 +272,22 @@ export {
createCharacterBanner,
updateCharacterBanner,
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
View 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 })
}

View File

@@ -111,7 +111,22 @@ export default {
title: '캐릭터 리스트',
route: '/character',
items: null
}
},
{
title: '큐레이션',
route: '/character/curation',
items: null
},
{
title: '정산',
route: '/character/calculate',
items: null
},
{
title: '원작',
route: '/original-work',
items: null
},
]
})
} else {

View File

@@ -270,6 +270,46 @@ const routes = [
name: 'CharacterBanner',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue')
},
{
path: '/character/images',
name: 'CharacterImageList',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageList.vue')
},
{
path: '/character/images/form',
name: 'CharacterImageForm',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue')
},
{
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')
},
]
},
{

View File

@@ -60,7 +60,7 @@
max-width="300"
>
<v-img
:src="banner.imageUrl"
:src="banner.imagePath"
height="200"
contain
/>

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

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>

View File

@@ -10,6 +10,29 @@
<v-spacer />
<v-toolbar-title>{{ isEdit ? '캐릭터 수정' : '캐릭터 등록' }}</v-toolbar-title>
<v-spacer />
<v-btn
small
outlined
color="primary"
@click="exportToJson"
>
JSON 다운로드
</v-btn>
<input
ref="importInput"
type="file"
accept="application/json,.json"
style="display:none"
@change="onImportFileChange"
>
<v-btn
small
color="primary"
class="ml-2"
@click="$refs.importInput.click()"
>
JSON 업로드
</v-btn>
</v-toolbar>
<v-container>
@@ -34,6 +57,7 @@
truncate-length="15"
outlined
dense
:class="{ 'required-asterisk': !isEdit }"
:rules="imageRules"
/>
</v-col>
@@ -62,6 +86,7 @@
v-model="character.name"
label="캐릭터명"
:rules="nameRules"
class="required-asterisk"
required
outlined
dense
@@ -76,6 +101,7 @@
v-model="character.description"
label="캐릭터 한 줄 소개"
:rules="descriptionRules"
class="required-asterisk"
required
outlined
dense
@@ -110,6 +136,7 @@
min="0"
outlined
dense
class="required-asterisk"
:rules="ageRules"
@input="validateNumberInput"
/>
@@ -179,29 +206,64 @@
</v-col>
</v-row>
<!-- 원작 정보 -->
<!-- 원작 선택 -->
<v-row>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="character.originalTitle"
label="원작명"
<v-col cols="12">
<v-autocomplete
v-model="selectedOriginalId"
:items="originalOptions"
:loading="originalLoading"
:search-input.sync="originalSearchTerm"
item-text="title"
item-value="id"
label="원작 검색 후 선택"
hide-no-data
hide-selected
clearable
outlined
dense
/>
@change="onOriginalChange"
>
<template v-slot:item="{ item, on, attrs }">
<v-list-item
v-bind="attrs"
v-on="on"
>
<v-list-item-avatar>
<v-img :src="item.imageUrl" />
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title v-text="item.title" />
<v-list-item-subtitle v-text="item.category" />
</v-list-item-content>
</v-list-item>
</template>
</v-autocomplete>
</v-col>
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="character.originalLink"
label="원작링크"
outlined
dense
/>
</v-row>
<v-row v-if="selectedOriginal">
<v-col cols="12">
<div class="d-flex align-center">
<v-avatar
size="60"
class="mr-3"
>
<v-img :src="selectedOriginal.imageUrl" />
</v-avatar>
<div>
<div class="subtitle-1">
{{ selectedOriginal.title }}
</div>
</div>
<v-spacer />
<v-btn
small
text
@click="clearSelectedOriginal"
>
해제
</v-btn>
</div>
</v-col>
</v-row>
@@ -221,12 +283,12 @@
<v-col cols="12">
<v-textarea
v-model="character.speechPattern"
label="말투/특징적 표현 (최대 500자)"
label="말투/특징적 표현 (최대 1000자)"
outlined
auto-grow
rows="4"
counter="500"
:rules="[v => v.length <= 500 || '최대 500자까지 입력 가능합니다']"
counter="1000"
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
/>
</v-col>
</v-row>
@@ -236,12 +298,12 @@
<v-col cols="12">
<v-textarea
v-model="character.speechStyle"
label="대화 스타일 (최대 200자)"
label="대화 스타일 (최대 1000자)"
outlined
auto-grow
rows="3"
counter="200"
:rules="[v => v.length <= 200 || '최대 200자까지 입력 가능합니다']"
rows="4"
counter="1000"
:rules="[v => v.length <= 1000 || '최대 1000자까지 입력 가능합니다']"
/>
</v-col>
</v-row>
@@ -270,6 +332,7 @@
outlined
auto-grow
rows="4"
:class="{ 'required-asterisk': !isEdit }"
:rules="systemPromptRules"
/>
<div
@@ -280,82 +343,6 @@
</v-col>
</v-row>
<!-- 인물 관계 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
인물 관계
</h3>
<v-row>
<v-col cols="12">
<v-row>
<v-col cols="11">
<v-text-field
v-model="newRelationship"
label="새 인물 관계 추가 (최대 200자)"
outlined
dense
counter="200"
:rules="[v => v.length <= 200 || '최대 200자까지 입력 가능합니다']"
@keyup.enter="addRelationship"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newRelationship.trim() || relationships.length >= 10"
@click="addRelationship"
>
추가
</v-btn>
</v-col>
</v-row>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="relationships.length > 0"
class="memory-list"
>
<v-list-item
v-for="(relationship, index) in relationships"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text">
{{ relationship }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeRelationship(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
인물 관계가 없습니다. 입력창에서 인물 관계를 추가해주세요. (최대 10)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 취미 목록 -->
<v-row>
@@ -881,6 +868,149 @@
</v-col>
</v-row>
<!-- 인물 관계 -->
<v-row>
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="mb-2">
인물 관계
</h3>
<v-row>
<v-col cols="12">
<!-- 1: 상대방 이름, 관계명 -->
<v-row>
<v-col cols="4">
<v-text-field
v-model="newRelationship.personName"
label="상대방 이름 (최대 10자)"
outlined
dense
counter="10"
/>
</v-col>
<v-col cols="8">
<v-text-field
v-model="newRelationship.relationshipName"
label="관계명 (어머니, 아버지, 친구 등) (최대 20자)"
outlined
dense
counter="20"
/>
</v-col>
</v-row>
<!-- 2: 관계 타입, 현재 상태, 중요도 -->
<v-row>
<v-col cols="5">
<v-text-field
v-model="newRelationship.relationshipType"
label="관계 타입 (가족, 친구, 동료, 연인, 기타 등) (최대 10자)"
outlined
dense
counter="10"
/>
</v-col>
<v-col cols="5">
<v-text-field
v-model="newRelationship.currentStatus"
label="현재 상태 (생존, 사망, 불명 등) (최대 10자)"
outlined
dense
counter="10"
/>
</v-col>
<v-col cols="2">
<v-text-field
v-model.number="newRelationship.importance"
label="중요도 (1~10)"
outlined
dense
type="number"
min="1"
max="10"
/>
</v-col>
</v-row>
</v-col>
</v-row>
<v-row>
<v-col cols="11">
<v-textarea
v-model="newRelationship.description"
label="관계 설명 (최대 500자)"
outlined
dense
auto-grow
rows="2"
counter="500"
:rules="relationshipDescriptionRules"
@keyup.enter="addRelationship"
/>
</v-col>
<v-col cols="1">
<v-btn
color="primary"
class="mt-1"
block
:disabled="!newRelationship.personName || !newRelationship.relationshipName || !newRelationship.relationshipType || !newRelationship.currentStatus || !newRelationship.importance || relationships.length >= 10"
@click="addRelationship"
>
추가
</v-btn>
</v-col>
</v-row>
<v-card
outlined
class="memory-container"
>
<v-list
v-if="relationships.length > 0"
class="memory-list"
>
<v-list-item
v-for="(relationship, index) in relationships"
:key="index"
class="memory-item"
>
<v-list-item-content>
<v-list-item-title class="memory-text font-weight-bold">
{{ relationship.personName }} - {{ relationship.relationshipName }}
</v-list-item-title>
<v-list-item-subtitle class="memory-text mt-1">
타입: {{ relationship.relationshipType }} | 상태: {{ relationship.currentStatus }} | 중요도: {{ relationship.importance }}
</v-list-item-subtitle>
<v-list-item-subtitle
v-if="relationship.description"
class="memory-text mt-1"
>
{{ relationship.description }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn
small
color="error"
class="delete-btn"
@click="removeRelationship(index)"
>
삭제
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-card-text
v-else
class="text-center grey--text"
>
인물 관계가 없습니다. 입력창에서 인물 관계를 추가해주세요. (최대 10)
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 활성화 상태 -->
<v-row v-show="isEdit">
<v-col cols="12">
@@ -923,6 +1053,7 @@
<script>
import {getCharacter, createCharacter, updateCharacter} from '@/api/character';
import { searchOriginals } from '@/api/original';
export default {
name: "CharacterForm",
@@ -936,7 +1067,14 @@ export default {
newMemoryTitle: '',
newMemoryContent: '',
newMemoryEmotion: '',
newRelationship: '',
newRelationship: {
personName: '',
relationshipName: '',
description: '',
importance: null,
relationshipType: '',
currentStatus: ''
},
newHobby: '',
newValue: '',
newGoal: '',
@@ -953,6 +1091,13 @@ export default {
personalities: [],
backgrounds: [],
originalCharacter: null, // 원본 캐릭터 데이터 저장용
// 원작 선택 상태
selectedOriginalId: null,
selectedOriginal: null,
originalOptions: [],
originalSearchTerm: '',
originalLoading: false,
originalDebounce: null,
character: {
id: null,
name: '',
@@ -964,6 +1109,7 @@ export default {
age: '',
mbti: '',
characterType: '',
originalWorkId: null,
originalTitle: '',
originalLink: '',
speechPattern: '',
@@ -1007,6 +1153,33 @@ export default {
typeOptions: ['Clone', 'Character'],
systemPromptRules: [
v => (this.isEdit ? true : (!!v && v.trim().length > 0) || '시스템 프롬프트를 입력하세요')
],
// 인물 관계 옵션 및 검증 규칙
relationshipTypeOptions: ['가족', '친구', '동료', '연인', '기타'],
relationshipStatusOptions: ['생존', '사망', '불명'],
personNameRules: [
v => (v === '' || v != null) || '상대방 이름을 입력하세요',
v => (!v || (typeof v === 'string' && v.length <= 10)) || '최대 10자까지 입력 가능합니다',
v => (!!v && v.trim().length > 0) || '상대방 이름을 입력하세요'
],
relationshipNameRules: [
v => (!!v && v.trim().length > 0) || '관계명을 입력하세요',
v => (typeof v === 'string' && v.length <= 20) || '최대 20자까지 입력 가능합니다'
],
relationshipDescriptionRules: [
v => (!v || (typeof v === 'string' && v.length <= 500)) || '최대 500자까지 입력 가능합니다'
],
relationshipImportanceRules: [
v => (v !== null && v !== '' && !isNaN(v)) || '중요도를 입력하세요',
v => (parseInt(v) >= 1 && parseInt(v) <= 10) || '1~10 사이 숫자만 입력 가능합니다'
],
relationshipTypeRules: [
v => (!!v && v.trim().length > 0) || '관계 타입을 입력하세요',
v => (typeof v === 'string' && v.length <= 10) || '최대 10자까지 입력 가능합니다'
],
relationshipStatusRules: [
v => (!!v && v.trim().length > 0) || '현재 상태를 입력하세요',
v => (typeof v === 'string' && v.length <= 10) || '최대 10자까지 입력 가능합니다'
]
}
},
@@ -1014,10 +1187,12 @@ export default {
computed: {
isSaveDisabled() {
if (this.isLoading) return true;
if (!this.isFormValid) return true;
if (!this.isEdit) return false; // 등록 시에는 변경 감지 없이 유효성만 확인
// 등록(create) 모드에서는 폼 유효성으로 판단
if (!this.isEdit) {
return !this.isFormValid;
}
// 수정 시에는 변경 사항이 있는 경우에만 저장 가능
// 수정(edit) 모드에서는 변경 사항이 있는지만 판단(필수값 유효성과 무관)
const changed = this.getChangedFields();
const hasNonIdField = Object.keys(changed || {}).some(k => k !== 'id');
const imageChanged = !!this.character.image; // 새 이미지 선택 여부
@@ -1034,6 +1209,14 @@ export default {
this.previewImage = null;
}
}
},
originalSearchTerm(val) {
if (this.originalDebounce) clearTimeout(this.originalDebounce);
if (!val || !val.trim()) {
this.originalOptions = [];
return;
}
this.originalDebounce = setTimeout(this.searchOriginalWorks, 300);
}
},
@@ -1054,6 +1237,55 @@ export default {
this.$dialog.notify.success(message);
},
async searchOriginalWorks() {
try {
this.originalLoading = true;
const term = (this.originalSearchTerm || '').trim();
if (!term) {
this.originalOptions = [];
return;
}
const res = await searchOriginals(term);
if (res && res.status === 200 && res.data && res.data.success === true) {
const data = res.data.data;
const items = (data && data.content) ? data.content : (Array.isArray(data) ? data : []);
this.originalOptions = items || [];
} else {
this.originalOptions = [];
}
} catch (e) {
this.originalOptions = [];
} finally {
this.originalLoading = false;
}
},
onOriginalChange(val) {
if (!val) {
this.selectedOriginal = null;
this.selectedOriginalId = null;
this.character.originalWorkId = null;
return;
}
const id = Number(val);
const found = (this.originalOptions || []).find(o => Number(o.id) === id);
if (found) {
this.selectedOriginal = { id: Number(found.id), title: found.title, imageUrl: found.imageUrl };
} else if (this.selectedOriginal && Number(this.selectedOriginal.id) === id) {
// keep current selectedOriginal
} else {
this.selectedOriginal = { id };
}
this.selectedOriginalId = id;
this.character.originalWorkId = id;
},
clearSelectedOriginal() {
this.selectedOriginal = null;
this.selectedOriginalId = null;
this.character.originalWorkId = null;
},
goBack() {
this.$router.push('/character');
},
@@ -1143,19 +1375,52 @@ export default {
},
addRelationship() {
if (this.newRelationship.trim()) {
if (this.relationships.length >= 10) {
this.notifyError('인물 관계는 최대 10개까지 등록 가능합니다.');
return;
}
if (this.newRelationship.length > 200) {
this.newRelationship = this.newRelationship.substring(0, 200);
}
this.relationships.unshift(this.newRelationship.trim());
this.newRelationship = '';
const r = this.newRelationship;
// 필수값 검사
if (!r.personName || !r.relationshipName || !r.relationshipType || !r.currentStatus || r.importance === null || r.importance === '') {
this.notifyError('상대방 이름, 관계명, 관계 타입, 현재 상태, 중요도를 모두 입력하세요.');
return;
}
if (this.relationships.length >= 10) {
this.notifyError('인물 관계는 최대 10개까지 등록 가능합니다.');
return;
}
// 길이 제한 적용
let personName = r.personName.trim().substring(0, 10);
let relationshipName = r.relationshipName.trim().substring(0, 20);
let description = (r.description || '').trim().substring(0, 500);
// 중요도 범위 보정
let importance = parseInt(r.importance);
if (isNaN(importance)) importance = 1;
importance = Math.max(1, Math.min(10, importance));
// 타입/상태 길이 제한 적용
let relationshipType = (r.relationshipType || '').trim().substring(0, 10);
let currentStatus = (r.currentStatus || '').trim().substring(0, 10);
const relationshipObj = {
personName,
relationshipName,
description,
importance,
relationshipType,
currentStatus
};
this.relationships.unshift(relationshipObj);
// 입력 필드 초기화
this.newRelationship = {
personName: '',
relationshipName: '',
description: '',
importance: null,
relationshipType: '',
currentStatus: ''
};
},
removeRelationship(index) {
@@ -1302,10 +1567,11 @@ export default {
// 로드된 캐릭터 데이터에서 null을 빈 문자열로 변환 (UI 표시용)
normalizeCharacterData(data) {
const result = { ...data };
// 기본값 보정
if (result.originalWorkId == null) result.originalWorkId = null;
const simpleFields = [
'name', 'systemPrompt', 'description', 'age', 'gender', 'mbti',
'characterType', 'originalTitle', 'originalLink', 'speechPattern',
'speechStyle', 'appearance', 'imageUrl'
'characterType', 'speechPattern', 'speechStyle', 'appearance', 'imageUrl'
];
simpleFields.forEach(f => {
if (result[f] == null) result[f] = '';
@@ -1325,8 +1591,7 @@ export default {
gender: this.character.gender,
mbti: this.character.mbti,
characterType: this.character.characterType,
originalTitle: this.character.originalTitle,
originalLink: this.character.originalLink,
originalWorkId: this.character.originalWorkId,
speechPattern: this.character.speechPattern,
speechStyle: this.character.speechStyle,
appearance: this.character.appearance,
@@ -1349,7 +1614,7 @@ export default {
// 기본 필드 비교
const simpleFields = [
'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalTitle', 'originalLink',
'name', 'description', 'age', 'gender', 'mbti', 'characterType', 'originalWorkId',
'speechPattern', 'speechStyle', 'isActive'
];
@@ -1369,7 +1634,7 @@ export default {
// 배열 필드 비교 (깊은 비교)
const arrayFields = [
'tags', 'hobbies', 'values', 'goals', 'relationships'
'tags', 'hobbies', 'values', 'goals'
];
arrayFields.forEach(field => {
@@ -1385,7 +1650,7 @@ export default {
// 복잡한 객체 배열 비교
const objectArrayFields = [
'personalities', 'backgrounds', 'memories'
'personalities', 'backgrounds', 'memories', 'relationships'
];
objectArrayFields.forEach(field => {
@@ -1399,6 +1664,15 @@ export default {
}
});
// 특수 규칙: 기존에 원작이 연결되어 있었고, 해제(선택 제거)한 경우 서버 규약에 따라 0으로 전송
if (this.isEdit && ('originalWorkId' in changedFields)) {
const prev = this.originalCharacter && this.originalCharacter.originalWorkId;
const curr = changedFields.originalWorkId;
if ((curr === null || curr === undefined || curr === '') && (prev !== null && prev !== undefined && Number(prev) > 0)) {
changedFields.originalWorkId = 0;
}
}
return changedFields;
},
@@ -1422,6 +1696,18 @@ export default {
image: null // 파일 입력은 초기화
};
// 원작 선택 UI 반영
if (this.character.originalWork) {
const d = this.character.originalWork;
this.selectedOriginal = d ? { id: Number(d.id), title: d.title, imageUrl: d.imageUrl } : null;
this.selectedOriginalId = d ? Number(d.id) : null;
this.character.originalWorkId = d ? Number(d.id) : null;
this.originalCharacter.originalWorkId = d ? Number(d.id) : null;
} else {
this.selectedOriginal = null;
this.selectedOriginalId = null;
}
// 태그, 메모리, 인물관계, 취미, 가치관, 목표, 성격 특성, 세계관 설정
this.tags = data.tags || [];
this.memories = data.memories || [];
@@ -1443,7 +1729,8 @@ export default {
},
async saveCharacter() {
if (!this.isFormValid) {
// 등록(create) 모드에서만 필수값 유효성 검사를 강제
if (!this.isEdit && !this.isFormValid) {
this.notifyError("필수 항목을 모두 입력하세요");
return;
}
@@ -1463,11 +1750,18 @@ export default {
this.character.personalities = [...this.personalities];
this.character.backgrounds = [...this.backgrounds];
// 선택된 원작 기준으로 originalWorkId 최종 반영
if (this.selectedOriginalId !== null) {
this.character.originalWorkId = this.selectedOriginalId;
}
let response;
if (this.isEdit) {
// 수정 시 변경된 필드만 전송
const changedData = this.getChangedFields();
// 인물관계는 삭제를 포함해 항상 서버와 동기화를 보장하기 위해 항상 포함
changedData.relationships = this.character.relationships || [];
response = await updateCharacter(changedData, this.character.image);
} else {
// 신규 등록 시 ID 필드를 제외한 데이터 전송
@@ -1488,6 +1782,146 @@ export default {
} finally {
this.isLoading = false;
}
},
// ===== JSON 내보내기/가져오기 =====
buildSerializablePayload() {
const c = {
...this.character,
tags: this.tags.filter(t => t && typeof t === 'string' && t.trim()),
hobbies: [...this.hobbies],
values: [...this.values],
goals: [...this.goals],
relationships: [...this.relationships],
personalities: [...this.personalities],
backgrounds: [...this.backgrounds],
memories: [...this.memories]
};
// 이미지 및 상태 관련 필드 제외
delete c.image;
delete c.imageUrl;
delete c.isActive;
return {
version: 1,
...c
};
},
exportToJson() {
try {
const payload = this.buildSerializablePayload();
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json;charset=utf-8' });
const filenameBase = (this.character.name || 'character').replace(/\s+/g, '_');
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${filenameBase}.character.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
this.notifySuccess('JSON 파일로 내보냈습니다.');
} catch (e) {
console.error(e);
this.notifyError('내보내기 중 오류가 발생했습니다.');
}
},
onImportFileChange(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const text = reader.result;
const data = JSON.parse(text);
this.applyImportedData(data);
this.notifySuccess('JSON 데이터를 불러왔습니다.');
} catch (err) {
console.error(err);
this.notifyError('유효한 JSON 파일이 아닙니다.');
} finally {
// 동일 파일 재업로드 허용
e.target.value = '';
}
};
reader.readAsText(file, 'utf-8');
},
applyImportedData(data) {
// 1) 버전/형식 검증
if (!data || (data.version !== 1 && data.version !== undefined)) {
throw new Error('지원되지 않는 파일 버전입니다.');
}
// 2) 안전한 추출(helper)
const arr = (v) => Array.isArray(v) ? v : [];
const str = (v) => (v == null ? '' : String(v));
// 3) 이미지 및 상태 관련 필드 강제 제거 (파일 포맷 요구사항)
if (data) {
delete data.image;
delete data.imageUrl;
delete data.isActive;
}
// 4) 기본 필드 주입
const patch = {
name: str(data.name),
description: str(data.description),
systemPrompt: str(data.systemPrompt),
age: str(data.age),
gender: str(data.gender),
mbti: str(data.mbti),
characterType: str(data.characterType),
originalWorkId: data.originalWorkId == null ? null : Number(data.originalWorkId),
speechPattern: str(data.speechPattern),
speechStyle: str(data.speechStyle),
appearance: str(data.appearance)
};
// 5) 배열 필드 주입 + 간단 검증/정제
const sanitizeString = (s, max) => String(s || '').trim().substring(0, max);
this.tags = arr(data.tags).map(t => sanitizeString(t, 50)).slice(0, 20);
this.hobbies = arr(data.hobbies).map(h => sanitizeString(h, 100)).slice(0, 10);
this.values = arr(data.values).map(v => sanitizeString(v, 100)).slice(0, 10);
this.goals = arr(data.goals).map(g => sanitizeString(g, 200)).slice(0, 10);
this.memories = arr(data.memories).map(m => ({
title: sanitizeString(m.title, 100),
content: sanitizeString(m.content, 1000),
emotion: sanitizeString(m.emotion, 50)
})).slice(0, 20);
this.relationships = arr(data.relationships).map(r => ({
personName: sanitizeString(r.personName, 10),
relationshipName: sanitizeString(r.relationshipName, 20),
description: sanitizeString(r.description, 500),
importance: Math.max(1, Math.min(10, parseInt(r.importance || 1))),
relationshipType: sanitizeString(r.relationshipType, 10),
currentStatus: sanitizeString(r.currentStatus, 10)
})).slice(0, 10);
this.personalities = arr(data.personalities).map(p => ({
trait: sanitizeString(p.trait, 100),
description: sanitizeString(p.description, 500)
})).slice(0, 10);
this.backgrounds = arr(data.backgrounds).map(b => ({
topic: sanitizeString(b.topic, 100),
description: sanitizeString(b.description, 1000)
})).slice(0, 10);
// 6) character에 반영 (image는 반드시 null 유지)
this.character = {
...this.character,
...patch,
image: null
};
}
}
}
@@ -1526,4 +1960,9 @@ export default {
.custom-caption {
font-size: 16px !important;
}
.required-asterisk >>> .v-label::after {
content: ' *';
color: #ff5252;
}
</style>

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

@@ -9,7 +9,7 @@
<br>
<v-container>
<v-row>
<v-row align="center">
<v-col cols="4">
<v-btn
color="primary"
@@ -19,6 +19,29 @@
캐릭터 추가
</v-btn>
</v-col>
<v-col
cols="8"
class="d-flex justify-end align-center"
>
<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
:style="{ backgroundColor: '#3bb9f1', color: 'white' }"
:disabled="is_loading"
@click="onSearch"
>
검색
</v-btn>
</v-col>
</v-row>
<v-row>
<v-col>
@@ -141,6 +164,16 @@
수정
</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
@@ -234,7 +267,7 @@
</template>
<script>
import { getCharacterList, updateCharacter } from '@/api/character'
import { getCharacterList, updateCharacter, searchCharacterList } from '@/api/character'
export default {
name: "CharacterList",
@@ -250,7 +283,8 @@ export default {
page: 1,
total_page: 0,
characters: [],
selected_character: {}
selected_character: {},
searchTerm: ''
}
},
@@ -306,6 +340,13 @@ export default {
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({
@@ -352,10 +393,18 @@ export default {
await this.getCharacters()
},
onSearch() {
this.page = 1;
this.getCharacters();
},
async getCharacters() {
this.is_loading = true
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.status === 200) {
if (response.data.success === true) {

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

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

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

View File

@@ -53,6 +53,24 @@
<template v-slot:item.communitySettlementRatio="{ item }">
{{ item.communitySettlementRatio }}%
</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-col>
</v-row>
@@ -73,13 +91,20 @@
persistent
>
<v-card>
<v-card-title>크리에이터 정산비율</v-card-title>
<v-card-text>
<v-card-title>{{ is_edit ? '크리에이터 정산비율 수정' : '크리에이터 정산비율' }}</v-card-title>
<v-card-text v-show="!is_edit">
<v-text-field
v-model="creator_settlement_ratio.creator_id"
label="크리에이터 번호"
/>
</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-text-field
v-model="creator_settlement_ratio.subsidy"
@@ -118,7 +143,7 @@
text
@click="validate"
>
등록하기
{{ is_edit ? '수정하기' : '등록하기' }}
</v-btn>
</v-card-actions>
</v-card>
@@ -142,6 +167,8 @@ export default {
items: [],
creator_settlement_ratio: {},
show_write_dialog: false,
is_edit: false,
editing_item_id: null,
headers: [
{
text: '닉네임',
@@ -173,6 +200,12 @@ export default {
sortable: false,
value: 'communitySettlementRatio',
},
{
text: '관리',
align: 'center',
sortable: false,
value: 'actions',
},
],
}
},
@@ -191,11 +224,16 @@ export default {
},
showWriteDialog() {
this.is_edit = false
this.editing_item_id = null
this.creator_settlement_ratio = {}
this.show_write_dialog = true
},
cancel() {
this.creator_settlement_ratio = {}
this.is_edit = false
this.editing_item_id = null
this.show_write_dialog = false
},
@@ -225,7 +263,11 @@ export default {
return
}
this.createCreatorSettlementRatio();
if (this.is_edit) {
this.updateCreatorSettlementRatio();
} else {
this.createCreatorSettlementRatio();
}
},
async createCreatorSettlementRatio() {
@@ -253,6 +295,71 @@ export default {
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() {
this.is_loading = true
@@ -279,10 +386,6 @@ export default {
},
async next() {
if (this.search_word.length < 2) {
this.search_word = ''
}
await this.getSettlementRatio()
},
},