feat(character-image): 캐릭터 이미지 관리(목록/등록/수정/삭제/정렬) 추가
This commit is contained in:
		| @@ -154,6 +154,52 @@ async function updateCharacterBannerOrder(bannerIds) { | |||||||
|   return Vue.axios.put('/admin/chat/banner/orders', {ids: bannerIds}) |   return Vue.axios.put('/admin/chat/banner/orders', {ids: bannerIds}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // 캐릭터 이미지 리스트 | ||||||
|  | async function getCharacterImageList(characterId, page = 1, size = 20) { | ||||||
|  |   return Vue.axios.get('/admin/chat/character/image/list', { | ||||||
|  |     params: { characterId, page: page - 1, size } | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 캐릭터 이미지 상세 | ||||||
|  | async function getCharacterImage(imageId) { | ||||||
|  |   return Vue.axios.get(`/admin/chat/character/image/${imageId}`) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 캐릭터 이미지 등록 | ||||||
|  | async function createCharacterImage(imageData) { | ||||||
|  |   const formData = new FormData() | ||||||
|  |   if (imageData.image) formData.append('image', imageData.image) | ||||||
|  |   const requestData = { | ||||||
|  |     characterId: imageData.characterId, | ||||||
|  |     imagePriceCan: imageData.imagePriceCan, | ||||||
|  |     messagePriceCan: imageData.messagePriceCan, | ||||||
|  |     isAdult: imageData.isAdult, | ||||||
|  |     triggers: imageData.triggers || [] | ||||||
|  |   } | ||||||
|  |   formData.append('request', JSON.stringify(requestData)) | ||||||
|  |   return Vue.axios.post('/admin/chat/character/image/register', formData, { | ||||||
|  |     headers: { 'Content-Type': 'multipart/form-data' } | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 캐릭터 이미지 수정 (트리거만 수정) | ||||||
|  | async function updateCharacterImage(imageData) { | ||||||
|  |   const imageId = imageData.imageId | ||||||
|  |   const payload = { triggers: imageData.triggers || [] } | ||||||
|  |   return Vue.axios.put(`/admin/chat/character/image/${imageId}/triggers`, payload) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 캐릭터 이미지 삭제 | ||||||
|  | async function deleteCharacterImage(imageId) { | ||||||
|  |   return Vue.axios.delete(`/admin/chat/character/image/${imageId}`) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // 캐릭터 이미지 순서 변경 | ||||||
|  | async function updateCharacterImageOrder(characterId, imageIds) { | ||||||
|  |   return Vue.axios.put('/admin/chat/character/image/orders', { characterId, ids: imageIds }) | ||||||
|  | } | ||||||
|  |  | ||||||
| export { | export { | ||||||
|   getCharacterList, |   getCharacterList, | ||||||
|   searchCharacters, |   searchCharacters, | ||||||
| @@ -164,5 +210,11 @@ export { | |||||||
|   createCharacterBanner, |   createCharacterBanner, | ||||||
|   updateCharacterBanner, |   updateCharacterBanner, | ||||||
|   deleteCharacterBanner, |   deleteCharacterBanner, | ||||||
|   updateCharacterBannerOrder |   updateCharacterBannerOrder, | ||||||
|  |   getCharacterImageList, | ||||||
|  |   getCharacterImage, | ||||||
|  |   createCharacterImage, | ||||||
|  |   updateCharacterImage, | ||||||
|  |   deleteCharacterImage, | ||||||
|  |   updateCharacterImageOrder | ||||||
| } | } | ||||||
|   | |||||||
| @@ -270,6 +270,16 @@ const routes = [ | |||||||
|                 name: 'CharacterBanner', |                 name: 'CharacterBanner', | ||||||
|                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue') |                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterBanner.vue') | ||||||
|             }, |             }, | ||||||
|  |             { | ||||||
|  |                 path: '/character/images', | ||||||
|  |                 name: 'CharacterImageList', | ||||||
|  |                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageList.vue') | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 path: '/character/images/form', | ||||||
|  |                 name: 'CharacterImageForm', | ||||||
|  |                 component: () => import(/* webpackChunkName: "character" */ '../views/Chat/CharacterImageForm.vue') | ||||||
|  |             }, | ||||||
|         ] |         ] | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|   | |||||||
							
								
								
									
										306
									
								
								src/views/Chat/CharacterImageForm.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										306
									
								
								src/views/Chat/CharacterImageForm.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,306 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <v-toolbar dark> | ||||||
|  |       <v-btn | ||||||
|  |         icon | ||||||
|  |         @click="goBack" | ||||||
|  |       > | ||||||
|  |         <v-icon>mdi-arrow-left</v-icon> | ||||||
|  |       </v-btn> | ||||||
|  |       <v-spacer /> | ||||||
|  |       <v-toolbar-title>{{ isEdit ? '이미지 수정' : '이미지 등록' }}</v-toolbar-title> | ||||||
|  |       <v-spacer /> | ||||||
|  |     </v-toolbar> | ||||||
|  |  | ||||||
|  |     <v-container> | ||||||
|  |       <v-card class="pa-4"> | ||||||
|  |         <v-form | ||||||
|  |           ref="form" | ||||||
|  |           v-model="isFormValid" | ||||||
|  |         > | ||||||
|  |           <v-card-text> | ||||||
|  |             <v-row> | ||||||
|  |               <v-col | ||||||
|  |                 v-show="!isEdit" | ||||||
|  |                 cols="12" | ||||||
|  |                 md="6" | ||||||
|  |               > | ||||||
|  |                 <v-file-input | ||||||
|  |                   v-if="!isEdit" | ||||||
|  |                   v-model="form.image" | ||||||
|  |                   label="이미지 (800x1000 비율 권장)" | ||||||
|  |                   accept="image/*" | ||||||
|  |                   prepend-icon="mdi-camera" | ||||||
|  |                   show-size | ||||||
|  |                   truncate-length="15" | ||||||
|  |                   outlined | ||||||
|  |                   dense | ||||||
|  |                   :rules="imageRules" | ||||||
|  |                 /> | ||||||
|  |               </v-col> | ||||||
|  |               <v-col | ||||||
|  |                 v-if="previewImage || form.imageUrl" | ||||||
|  |                 cols="12" | ||||||
|  |                 :md="isEdit ? 12 : 6" | ||||||
|  |               > | ||||||
|  |                 <div class="text-center"> | ||||||
|  |                   <v-img | ||||||
|  |                     :src="previewImage || form.imageUrl" | ||||||
|  |                     max-height="240" | ||||||
|  |                     :aspect-ratio="0.8" | ||||||
|  |                     contain | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |               </v-col> | ||||||
|  |             </v-row> | ||||||
|  |  | ||||||
|  |             <v-row> | ||||||
|  |               <v-col | ||||||
|  |                 cols="12" | ||||||
|  |                 md="6" | ||||||
|  |               > | ||||||
|  |                 <v-text-field | ||||||
|  |                   v-model.number="form.soloPurchasePriceCan" | ||||||
|  |                   label="이미지 단독 구매 가격(캔)" | ||||||
|  |                   type="number" | ||||||
|  |                   min="0" | ||||||
|  |                   outlined | ||||||
|  |                   dense | ||||||
|  |                   :disabled="isEdit" | ||||||
|  |                 /> | ||||||
|  |               </v-col> | ||||||
|  |               <v-col | ||||||
|  |                 cols="12" | ||||||
|  |                 md="6" | ||||||
|  |               > | ||||||
|  |                 <v-text-field | ||||||
|  |                   v-model.number="form.messagePurchasePriceCan" | ||||||
|  |                   label="메시지에서 구매 가격(캔)" | ||||||
|  |                   type="number" | ||||||
|  |                   min="0" | ||||||
|  |                   outlined | ||||||
|  |                   dense | ||||||
|  |                   :disabled="isEdit" | ||||||
|  |                 /> | ||||||
|  |               </v-col> | ||||||
|  |             </v-row> | ||||||
|  |  | ||||||
|  |             <v-row> | ||||||
|  |               <v-col | ||||||
|  |                 cols="12" | ||||||
|  |                 md="6" | ||||||
|  |               > | ||||||
|  |                 <v-switch | ||||||
|  |                   v-model="form.adult" | ||||||
|  |                   label="성인 이미지 여부" | ||||||
|  |                   inset | ||||||
|  |                   :disabled="isEdit" | ||||||
|  |                 /> | ||||||
|  |               </v-col> | ||||||
|  |             </v-row> | ||||||
|  |  | ||||||
|  |             <v-row> | ||||||
|  |               <v-col cols="12"> | ||||||
|  |                 <v-combobox | ||||||
|  |                   v-model="triggers" | ||||||
|  |                   label="트리거 단어 입력" | ||||||
|  |                   multiple | ||||||
|  |                   chips | ||||||
|  |                   small-chips | ||||||
|  |                   deletable-chips | ||||||
|  |                   outlined | ||||||
|  |                   dense | ||||||
|  |                   :rules="triggerRules" | ||||||
|  |                   @keydown.space.prevent="addTrigger" | ||||||
|  |                 > | ||||||
|  |                   <template v-slot:selection="{ attrs, item, select, selected }"> | ||||||
|  |                     <v-chip | ||||||
|  |                       v-bind="attrs" | ||||||
|  |                       :input-value="selected" | ||||||
|  |                       close | ||||||
|  |                       @click="select" | ||||||
|  |                       @click:close="removeTrigger(item)" | ||||||
|  |                     > | ||||||
|  |                       {{ item }} | ||||||
|  |                     </v-chip> | ||||||
|  |                   </template> | ||||||
|  |                 </v-combobox> | ||||||
|  |                 <div class="caption grey--text text--darken-1"> | ||||||
|  |                   트리거를 입력하고 엔터를 누르면 추가됩니다. (20자 이내, 최소 5개, 최대 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 >= 5 && v.length <= 10) || '트리거는 최소 5개, 최대 10개까지 등록 가능합니다' | ||||||
|  |       ], | ||||||
|  |       imageRules: [ | ||||||
|  |         v => !!v || '이미지를 선택하세요' | ||||||
|  |       ] | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   computed: { | ||||||
|  |     canSubmit() { | ||||||
|  |       const triggersValid = this.triggers && this.triggers.length >= 5 && 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 | ||||||
|  |       // 트리거 개수 검증: 최소 5개, 최대 10개 | ||||||
|  |       if (!this.triggers || this.triggers.length < 5 || this.triggers.length > 10) { | ||||||
|  |         this.notifyError('트리거는 최소 5개, 최대 10개여야 합니다.') | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       this.isSubmitting = true | ||||||
|  |       try { | ||||||
|  |         if (this.isEdit) { | ||||||
|  |           const resp = await updateCharacterImage({ imageId: this.imageId, triggers: this.triggers }) | ||||||
|  |           if (resp && resp.status === 200 && resp.data && resp.data.success === true) { | ||||||
|  |             this.notifySuccess('수정되었습니다.') | ||||||
|  |             this.goBack() | ||||||
|  |           } else { | ||||||
|  |             this.notifyError('수정에 실패했습니다.') | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           const resp = await createCharacterImage({ | ||||||
|  |             characterId: this.characterId, | ||||||
|  |             image: this.form.image, | ||||||
|  |             imagePriceCan: this.form.soloPurchasePriceCan, | ||||||
|  |             messagePriceCan: this.form.messagePurchasePriceCan, | ||||||
|  |             isAdult: this.form.adult, | ||||||
|  |             triggers: this.triggers | ||||||
|  |           }) | ||||||
|  |           if (resp && resp.status === 200 && resp.data && resp.data.success === true) { | ||||||
|  |             this.notifySuccess('등록되었습니다.') | ||||||
|  |             this.goBack() | ||||||
|  |           } else { | ||||||
|  |             this.notifyError('등록에 실패했습니다.') | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } catch (e) { | ||||||
|  |         console.error('이미지 저장 오류:', e) | ||||||
|  |         this.notifyError('작업 중 오류가 발생했습니다.') | ||||||
|  |       } finally { | ||||||
|  |         this.isSubmitting = false | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | </style> | ||||||
							
								
								
									
										325
									
								
								src/views/Chat/CharacterImageList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/views/Chat/CharacterImageList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,325 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <v-toolbar dark> | ||||||
|  |       <v-btn | ||||||
|  |         icon | ||||||
|  |         @click="goBack" | ||||||
|  |       > | ||||||
|  |         <v-icon>mdi-arrow-left</v-icon> | ||||||
|  |       </v-btn> | ||||||
|  |       <v-spacer /> | ||||||
|  |       <v-toolbar-title>캐릭터 이미지 관리</v-toolbar-title> | ||||||
|  |       <v-spacer /> | ||||||
|  |     </v-toolbar> | ||||||
|  |  | ||||||
|  |     <v-container> | ||||||
|  |       <v-row class="align-center mb-4"> | ||||||
|  |         <v-col | ||||||
|  |           cols="12" | ||||||
|  |           md="6" | ||||||
|  |         > | ||||||
|  |           <div class="subtitle-1"> | ||||||
|  |             캐릭터: {{ characterName || characterId }} | ||||||
|  |           </div> | ||||||
|  |         </v-col> | ||||||
|  |         <v-col | ||||||
|  |           cols="12" | ||||||
|  |           md="6" | ||||||
|  |           class="text-right" | ||||||
|  |         > | ||||||
|  |           <v-btn | ||||||
|  |             color="primary" | ||||||
|  |             dark | ||||||
|  |             @click="goToAdd" | ||||||
|  |           > | ||||||
|  |             이미지 추가 | ||||||
|  |           </v-btn> | ||||||
|  |         </v-col> | ||||||
|  |       </v-row> | ||||||
|  |  | ||||||
|  |       <!-- 로딩 --> | ||||||
|  |       <v-row v-if="isLoading && images.length === 0"> | ||||||
|  |         <v-col class="text-center"> | ||||||
|  |           <v-progress-circular | ||||||
|  |             indeterminate | ||||||
|  |             color="primary" | ||||||
|  |             size="48" | ||||||
|  |           /> | ||||||
|  |         </v-col> | ||||||
|  |       </v-row> | ||||||
|  |  | ||||||
|  |       <!-- 목록 --> | ||||||
|  |       <draggable | ||||||
|  |         v-if="images.length > 0" | ||||||
|  |         v-model="images" | ||||||
|  |         class="image-grid" | ||||||
|  |         :options="{ animation: 150 }" | ||||||
|  |         @end="onDragEnd" | ||||||
|  |       > | ||||||
|  |         <div | ||||||
|  |           v-for="img in images" | ||||||
|  |           :key="img.id" | ||||||
|  |           class="image-card" | ||||||
|  |         > | ||||||
|  |           <v-card> | ||||||
|  |             <div class="image-wrapper"> | ||||||
|  |               <v-img | ||||||
|  |                 :src="img.imageUrl" | ||||||
|  |                 :aspect-ratio="0.8" | ||||||
|  |                 contain | ||||||
|  |               /> | ||||||
|  |               <div | ||||||
|  |                 v-if="img.isAdult" | ||||||
|  |                 class="ribbon" | ||||||
|  |               > | ||||||
|  |                 성인 | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |             <v-card-text class="pt-2"> | ||||||
|  |               <div class="price-row d-flex align-center"> | ||||||
|  |                 <div class="price-label"> | ||||||
|  |                   단독 : | ||||||
|  |                 </div> | ||||||
|  |                 <div class="price-value"> | ||||||
|  |                   {{ img.imagePriceCan }} 캔 | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |               <div class="price-row d-flex align-center"> | ||||||
|  |                 <div class="price-label"> | ||||||
|  |                   메시지 : | ||||||
|  |                 </div> | ||||||
|  |                 <div class="price-value"> | ||||||
|  |                   {{ img.messagePriceCan }} 캔 | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |               <div class="mt-2"> | ||||||
|  |                 <v-chip | ||||||
|  |                   v-for="(t, i) in (img.triggers || [])" | ||||||
|  |                   :key="i" | ||||||
|  |                   small | ||||||
|  |                   class="ma-1" | ||||||
|  |                   color="primary" | ||||||
|  |                   text-color="white" | ||||||
|  |                 > | ||||||
|  |                   {{ t }} | ||||||
|  |                 </v-chip> | ||||||
|  |               </div> | ||||||
|  |             </v-card-text> | ||||||
|  |             <v-card-actions> | ||||||
|  |               <v-spacer /> | ||||||
|  |               <v-btn | ||||||
|  |                 small | ||||||
|  |                 color="primary" | ||||||
|  |                 @click="goToEdit(img)" | ||||||
|  |               > | ||||||
|  |                 수정 | ||||||
|  |               </v-btn> | ||||||
|  |               <v-btn | ||||||
|  |                 small | ||||||
|  |                 color="error" | ||||||
|  |                 @click="confirmDelete(img)" | ||||||
|  |               > | ||||||
|  |                 삭제 | ||||||
|  |               </v-btn> | ||||||
|  |             </v-card-actions> | ||||||
|  |           </v-card> | ||||||
|  |         </div> | ||||||
|  |       </draggable> | ||||||
|  |  | ||||||
|  |       <!-- 데이터 없음 --> | ||||||
|  |       <v-row v-if="!isLoading && images.length === 0"> | ||||||
|  |         <v-col class="text-center grey--text"> | ||||||
|  |           등록된 이미지가 없습니다. | ||||||
|  |         </v-col> | ||||||
|  |       </v-row> | ||||||
|  |  | ||||||
|  |       <!-- 삭제 확인 다이얼로그 --> | ||||||
|  |       <v-dialog | ||||||
|  |         v-model="showDeleteDialog" | ||||||
|  |         max-width="400" | ||||||
|  |       > | ||||||
|  |         <v-card> | ||||||
|  |           <v-card-title class="headline"> | ||||||
|  |             이미지 삭제 | ||||||
|  |           </v-card-title> | ||||||
|  |           <v-card-text>삭제하시겠습니까?</v-card-text> | ||||||
|  |           <v-card-actions> | ||||||
|  |             <v-spacer /> | ||||||
|  |             <v-btn | ||||||
|  |               text | ||||||
|  |               color="blue darken-1" | ||||||
|  |               @click="showDeleteDialog = false" | ||||||
|  |             > | ||||||
|  |               취소 | ||||||
|  |             </v-btn> | ||||||
|  |             <v-btn | ||||||
|  |               text | ||||||
|  |               color="red darken-1" | ||||||
|  |               :loading="isSubmitting" | ||||||
|  |               @click="deleteImage" | ||||||
|  |             > | ||||||
|  |               삭제 | ||||||
|  |             </v-btn> | ||||||
|  |           </v-card-actions> | ||||||
|  |         </v-card> | ||||||
|  |       </v-dialog> | ||||||
|  |     </v-container> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script> | ||||||
|  | import { getCharacterImageList, deleteCharacterImage, updateCharacterImageOrder } from '@/api/character' | ||||||
|  | import draggable from 'vuedraggable' | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |   name: 'CharacterImageList', | ||||||
|  |   components: { draggable }, | ||||||
|  |   data() { | ||||||
|  |     return { | ||||||
|  |       isLoading: false, | ||||||
|  |       isSubmitting: false, | ||||||
|  |       images: [], | ||||||
|  |       characterId: null, | ||||||
|  |       characterName: this.$route.query.name || '', | ||||||
|  |       showDeleteDialog: false, | ||||||
|  |       selectedImage: null | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   created() { | ||||||
|  |     this.characterId = Number(this.$route.query.characterId) | ||||||
|  |     if (!this.characterId) { | ||||||
|  |       this.notifyError('캐릭터 ID가 없습니다.'); | ||||||
|  |       this.goBack() | ||||||
|  |       return | ||||||
|  |     } | ||||||
|  |     this.loadImages() | ||||||
|  |   }, | ||||||
|  |   methods: { | ||||||
|  |     notifyError(message) { this.$dialog.notify.error(message) }, | ||||||
|  |     notifySuccess(message) { this.$dialog.notify.success(message) }, | ||||||
|  |     goBack() { this.$router.push('/character') }, | ||||||
|  |     async loadImages() { | ||||||
|  |       this.isLoading = true | ||||||
|  |       try { | ||||||
|  |         const resp = await getCharacterImageList(this.characterId, 1, 20) | ||||||
|  |         if (resp && resp.status === 200 && resp.data && resp.data.success === true) { | ||||||
|  |           const data = resp.data.data | ||||||
|  |           this.images = (data.content || data || []) | ||||||
|  |         } else { | ||||||
|  |           this.notifyError('이미지 목록을 불러오지 못했습니다.') | ||||||
|  |         } | ||||||
|  |       } catch (e) { | ||||||
|  |         console.error('이미지 목록 오류:', e) | ||||||
|  |         this.notifyError('이미지 목록 조회 중 오류가 발생했습니다.') | ||||||
|  |       } finally { | ||||||
|  |         this.isLoading = false | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     goToAdd() { | ||||||
|  |       this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, name: this.characterName } }) | ||||||
|  |     }, | ||||||
|  |     goToEdit(img) { | ||||||
|  |       this.$router.push({ path: '/character/images/form', query: { characterId: this.characterId, imageId: img.id, name: this.characterName } }) | ||||||
|  |     }, | ||||||
|  |     confirmDelete(img) { | ||||||
|  |       this.selectedImage = img | ||||||
|  |       this.showDeleteDialog = true | ||||||
|  |     }, | ||||||
|  |     async deleteImage() { | ||||||
|  |       if (!this.selectedImage || this.isSubmitting) return | ||||||
|  |       this.isSubmitting = true | ||||||
|  |       try { | ||||||
|  |         const resp = await deleteCharacterImage(this.selectedImage.id) | ||||||
|  |         if (resp && resp.status === 200 && resp.data && resp.data.success === true) { | ||||||
|  |           this.notifySuccess('삭제되었습니다.') | ||||||
|  |           this.showDeleteDialog = false | ||||||
|  |           await this.loadImages() | ||||||
|  |         } else { | ||||||
|  |           this.notifyError('삭제에 실패했습니다.') | ||||||
|  |         } | ||||||
|  |       } catch (e) { | ||||||
|  |         console.error('이미지 삭제 오류:', e) | ||||||
|  |         this.notifyError('삭제 중 오류가 발생했습니다.') | ||||||
|  |       } finally { | ||||||
|  |         this.isSubmitting = false | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     async onDragEnd() { | ||||||
|  |       try { | ||||||
|  |         const ids = this.images.map(img => img.id) | ||||||
|  |         const resp = await updateCharacterImageOrder(this.characterId, ids) | ||||||
|  |         if (resp && resp.status === 200 && resp.data && resp.data.success === true) { | ||||||
|  |           this.notifySuccess('이미지 순서가 변경되었습니다.') | ||||||
|  |         } else { | ||||||
|  |           this.notifyError('이미지 순서 변경에 실패했습니다.') | ||||||
|  |           await this.loadImages() | ||||||
|  |         } | ||||||
|  |       } catch (e) { | ||||||
|  |         console.error('이미지 순서 변경 오류:', e) | ||||||
|  |         this.notifyError('이미지 순서 변경에 실패했습니다.') | ||||||
|  |         await this.loadImages() | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .image-grid { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: repeat(5, 1fr); | ||||||
|  |   gap: 16px; | ||||||
|  | } | ||||||
|  | .image-card { | ||||||
|  |   width: 100%; | ||||||
|  | } | ||||||
|  | @media (max-width: 1264px) { | ||||||
|  |   .image-grid { grid-template-columns: repeat(4, 1fr); } | ||||||
|  | } | ||||||
|  | @media (max-width: 960px) { | ||||||
|  |   .image-grid { grid-template-columns: repeat(3, 1fr); } | ||||||
|  | } | ||||||
|  | @media (max-width: 600px) { | ||||||
|  |   .image-grid { grid-template-columns: repeat(2, 1fr); } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Image wrapper for overlays */ | ||||||
|  | .image-wrapper { | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Ribbon style for adult indicator */ | ||||||
|  | .ribbon { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   right: 0; | ||||||
|  |   z-index: 2; | ||||||
|  |   background: #e53935; /* red darken-1 */ | ||||||
|  |   color: #fff; | ||||||
|  |   padding: 6px 20px; | ||||||
|  |   font-weight: 500; | ||||||
|  |   font-size: 14px; | ||||||
|  |   text-transform: none; | ||||||
|  |   box-shadow: 0 2px 6px rgba(0,0,0,0.2); | ||||||
|  |   pointer-events: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Price rows styling */ | ||||||
|  | .price-row { | ||||||
|  |   font-size: 16px; | ||||||
|  |   line-height: 1.6; | ||||||
|  |   margin-bottom: 4px; | ||||||
|  | } | ||||||
|  | .price-label { | ||||||
|  |   width: 72px; /* 긴 쪽 기준으로 라벨 고정폭 */ | ||||||
|  |   text-align: left; | ||||||
|  |   color: rgba(0,0,0,0.6); | ||||||
|  |   font-weight: 700; | ||||||
|  | } | ||||||
|  | .price-value { | ||||||
|  |   flex: 1; | ||||||
|  |   font-weight: 700; | ||||||
|  |   color: rgba(0,0,0,0.87); | ||||||
|  |   text-align: left; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -141,6 +141,16 @@ | |||||||
|                           수정 |                           수정 | ||||||
|                         </v-btn> |                         </v-btn> | ||||||
|                       </v-col> |                       </v-col> | ||||||
|  |                       <v-col> | ||||||
|  |                         <v-btn | ||||||
|  |                           small | ||||||
|  |                           color="info" | ||||||
|  |                           :disabled="is_loading" | ||||||
|  |                           @click="goToImageList(item)" | ||||||
|  |                         > | ||||||
|  |                           이미지 | ||||||
|  |                         </v-btn> | ||||||
|  |                       </v-col> | ||||||
|                       <v-col> |                       <v-col> | ||||||
|                         <v-btn |                         <v-btn | ||||||
|                           small |                           small | ||||||
| @@ -306,6 +316,13 @@ export default { | |||||||
|       this.$router.push('/character/form'); |       this.$router.push('/character/form'); | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|  |     goToImageList(item) { | ||||||
|  |       this.$router.push({ | ||||||
|  |         path: '/character/images', | ||||||
|  |         query: { characterId: item.id, name: item.name } | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |  | ||||||
|     showEditDialog(item) { |     showEditDialog(item) { | ||||||
|       // 페이지로 이동하면서 id 전달 |       // 페이지로 이동하면서 id 전달 | ||||||
|       this.$router.push({ |       this.$router.push({ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung