feat(original): 원작

- 등록, 수정, 삭제
- 캐릭터 연결, 해제 기능 추가
This commit is contained in:
Yu Sung
2025-09-15 04:27:22 +09:00
parent cd86973b60
commit 6507b025de
6 changed files with 934 additions and 3 deletions

80
src/api/original.js Normal file
View File

@@ -0,0 +1,80 @@
import Vue from 'vue';
// 공통: 빈 문자열 -> null
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)
};
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 => {
const value = data[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/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 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

@@ -122,6 +122,11 @@ export default {
route: '/character/calculate', route: '/character/calculate',
items: null items: null
}, },
{
title: '원작',
route: '/original-work',
items: null
},
] ]
}) })
} else { } else {

View File

@@ -291,9 +291,24 @@ const routes = [
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue') component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue')
}, },
{ {
path: '/character/calculate', path: '/character/calculate',
name: 'CharacterCalculate', name: 'CharacterCalculate',
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCalculateList.vue') 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

@@ -0,0 +1,321 @@
<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>
원작 링크:
<a
v-if="detail.originalLink"
:href="detail.originalLink"
target="_blank"
rel="noopener"
>{{ detail.originalLink }}</a>
<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,305 @@
<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-switch
v-model="form.isAdult"
label="19금 여부"
inset
/>
</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">
<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,
form: {
id: null,
image: null,
imageUrl: null,
title: '',
contentType: '',
category: '',
isAdult: false,
description: '',
originalLink: ''
},
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'];
return fields.some(f => this.form[f] !== this.originalInitial[f]);
},
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') },
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 || ''
}
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 || ''
}
} 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'];
const patch = { id: this.form.id };
if (this.originalInitial) {
fields.forEach(f => {
if (this.form[f] !== this.originalInitial[f]) {
patch[f] = this.form[f];
}
});
}
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>