Compare commits
4 Commits
18b59b5598
...
492859dae3
Author | SHA1 | Date | |
---|---|---|---|
492859dae3 | |||
![]() |
edab727c22 | ||
![]() |
00b12d0edb | ||
![]() |
6507b025de |
@@ -43,8 +43,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),
|
||||
|
87
src/api/original.js
Normal file
87
src/api/original.js
Normal file
@@ -0,0 +1,87 @@
|
||||
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 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 })
|
||||
}
|
@@ -122,6 +122,11 @@ export default {
|
||||
route: '/character/calculate',
|
||||
items: null
|
||||
},
|
||||
{
|
||||
title: '원작',
|
||||
route: '/original-work',
|
||||
items: null
|
||||
},
|
||||
]
|
||||
})
|
||||
} else {
|
||||
|
@@ -291,9 +291,24 @@ const routes = [
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCurationDetail.vue')
|
||||
},
|
||||
{
|
||||
path: '/character/calculate',
|
||||
name: 'CharacterCalculate',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCalculateList.vue')
|
||||
path: '/character/calculate',
|
||||
name: 'CharacterCalculate',
|
||||
component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterCalculateList.vue')
|
||||
},
|
||||
{
|
||||
path: '/original-work',
|
||||
name: 'OriginalList',
|
||||
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalList.vue')
|
||||
},
|
||||
{
|
||||
path: '/original-work/form',
|
||||
name: 'OriginalForm',
|
||||
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalForm.vue')
|
||||
},
|
||||
{
|
||||
path: '/original-work/detail',
|
||||
name: 'OriginalDetail',
|
||||
component: () => import(/* webpackChunkName: "original" */ '../views/Chat/OriginalDetail.vue')
|
||||
},
|
||||
]
|
||||
},
|
||||
|
@@ -60,7 +60,7 @@
|
||||
max-width="300"
|
||||
>
|
||||
<v-img
|
||||
:src="banner.imageUrl"
|
||||
:src="banner.imagePath"
|
||||
height="200"
|
||||
contain
|
||||
/>
|
||||
|
@@ -206,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>
|
||||
|
||||
@@ -1018,6 +1053,7 @@
|
||||
|
||||
<script>
|
||||
import {getCharacter, createCharacter, updateCharacter} from '@/api/character';
|
||||
import { searchOriginals } from '@/api/original';
|
||||
|
||||
export default {
|
||||
name: "CharacterForm",
|
||||
@@ -1055,6 +1091,13 @@ export default {
|
||||
personalities: [],
|
||||
backgrounds: [],
|
||||
originalCharacter: null, // 원본 캐릭터 데이터 저장용
|
||||
// 원작 선택 상태
|
||||
selectedOriginalId: null,
|
||||
selectedOriginal: null,
|
||||
originalOptions: [],
|
||||
originalSearchTerm: '',
|
||||
originalLoading: false,
|
||||
originalDebounce: null,
|
||||
character: {
|
||||
id: null,
|
||||
name: '',
|
||||
@@ -1066,6 +1109,7 @@ export default {
|
||||
age: '',
|
||||
mbti: '',
|
||||
characterType: '',
|
||||
originalWorkId: null,
|
||||
originalTitle: '',
|
||||
originalLink: '',
|
||||
speechPattern: '',
|
||||
@@ -1165,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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1185,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');
|
||||
},
|
||||
@@ -1466,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] = '';
|
||||
@@ -1489,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,
|
||||
@@ -1513,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'
|
||||
];
|
||||
|
||||
@@ -1563,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;
|
||||
},
|
||||
|
||||
@@ -1586,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 || [];
|
||||
@@ -1628,6 +1750,11 @@ 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) {
|
||||
@@ -1750,8 +1877,7 @@ export default {
|
||||
gender: str(data.gender),
|
||||
mbti: str(data.mbti),
|
||||
characterType: str(data.characterType),
|
||||
originalTitle: str(data.originalTitle),
|
||||
originalLink: str(data.originalLink),
|
||||
originalWorkId: data.originalWorkId == null ? null : Number(data.originalWorkId),
|
||||
speechPattern: str(data.speechPattern),
|
||||
speechStyle: str(data.speechStyle),
|
||||
appearance: str(data.appearance)
|
||||
|
321
src/views/Chat/OriginalDetail.vue
Normal file
321
src/views/Chat/OriginalDetail.vue
Normal 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>
|
305
src/views/Chat/OriginalForm.vue
Normal file
305
src/views/Chat/OriginalForm.vue
Normal 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>
|
205
src/views/Chat/OriginalList.vue
Normal file
205
src/views/Chat/OriginalList.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-toolbar dark>
|
||||
<v-spacer />
|
||||
<v-toolbar-title>원작 리스트</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
dark
|
||||
@click="goToCreate"
|
||||
>
|
||||
원작 등록
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-container>
|
||||
<v-row v-if="isLoading && originals.length === 0">
|
||||
<v-col class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="64"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="item in originals"
|
||||
:key="item.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<v-card
|
||||
class="mx-auto"
|
||||
max-width="344"
|
||||
style="cursor:pointer;"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<v-img
|
||||
:src="item.imageUrl"
|
||||
height="200"
|
||||
contain
|
||||
/>
|
||||
<v-card-title class="text-no-wrap">
|
||||
{{ item.title }}
|
||||
</v-card-title>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
small
|
||||
color="primary"
|
||||
@click.stop="editOriginal(item)"
|
||||
>
|
||||
수정
|
||||
</v-btn>
|
||||
<v-btn
|
||||
small
|
||||
color="error"
|
||||
@click.stop="confirmDelete(item)"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!isLoading && originals.length === 0">
|
||||
<v-col class="text-center">
|
||||
데이터가 없습니다.
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="isLoading && originals.length > 0">
|
||||
<v-col class="text-center">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-dialog
|
||||
v-model="deleteDialog"
|
||||
max-width="400"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
삭제 확인
|
||||
</v-card-title>
|
||||
<v-card-text>정말 삭제하시겠습니까?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
text
|
||||
@click="deleteDialog = false"
|
||||
>
|
||||
취소
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
text
|
||||
@click="deleteItem"
|
||||
>
|
||||
삭제
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getOriginalList, deleteOriginal } from '@/api/original'
|
||||
|
||||
export default {
|
||||
name: 'OriginalList',
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
originals: [],
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
deleteDialog: false,
|
||||
selected: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadMore();
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
methods: {
|
||||
notifyError(message) { this.$dialog.notify.error(message) },
|
||||
notifySuccess(message) { this.$dialog.notify.success(message) },
|
||||
async loadMore() {
|
||||
if (this.isLoading || !this.hasMore) return;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const res = await getOriginalList(this.page);
|
||||
if (res.status === 200 && res.data.success === true) {
|
||||
const content = res.data.data?.content || [];
|
||||
this.originals = this.originals.concat(content);
|
||||
this.hasMore = content.length > 0;
|
||||
this.page++;
|
||||
} else {
|
||||
this.notifyError('원작 목록 조회 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('원작 목록 조회 실패');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
handleScroll() {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const documentHeight = document.documentElement.offsetHeight;
|
||||
if (scrollPosition >= documentHeight - 200 && !this.isLoading && this.hasMore) {
|
||||
this.loadMore();
|
||||
}
|
||||
},
|
||||
goToCreate() {
|
||||
this.$router.push('/original-work/form');
|
||||
},
|
||||
editOriginal(item) {
|
||||
this.$router.push({ path: '/original-work/form', query: { id: item.id } });
|
||||
},
|
||||
openDetail(item) {
|
||||
this.$router.push({ path: '/original-work/detail', query: { id: item.id } });
|
||||
},
|
||||
confirmDelete(item) {
|
||||
this.selected = item;
|
||||
this.deleteDialog = true;
|
||||
},
|
||||
async deleteItem() {
|
||||
if (!this.selected) return;
|
||||
try {
|
||||
const res = await deleteOriginal(this.selected.id);
|
||||
if (res.status === 200 && res.data.success === true) {
|
||||
this.notifySuccess('삭제되었습니다.');
|
||||
this.originals = this.originals.filter(x => x.id !== this.selected.id);
|
||||
} else {
|
||||
this.notifyError('삭제 실패');
|
||||
}
|
||||
} catch (e) {
|
||||
this.notifyError('삭제 실패');
|
||||
} finally {
|
||||
this.deleteDialog = false;
|
||||
this.selected = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-no-wrap { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
</style>
|
Reference in New Issue
Block a user