feat: 관리자 수동 이미지 대체 기능 추가
This commit is contained in:
@@ -16,6 +16,11 @@ export function useAdminCustomItems({
|
||||
customItemModalDraftLabel,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
customItemReplacementItems,
|
||||
customItemReplacementLoading,
|
||||
customItemReplacementTargetId,
|
||||
customItemReplacementBusy,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
@@ -56,12 +61,41 @@ export function useAdminCustomItems({
|
||||
customItemModalHistoryActive.value = true
|
||||
}
|
||||
|
||||
async function refreshReplacementCandidates() {
|
||||
const currentItemId = modalTargetCustomItem.value?.id || ''
|
||||
if (!currentItemId) {
|
||||
customItemReplacementItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemReplacementLoading.value = true
|
||||
const data = await api.listAdminCustomItems({
|
||||
q: customItemReplacementQuery.value,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
filter: 'all',
|
||||
})
|
||||
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
|
||||
} catch (e) {
|
||||
error.value = '대체할 이미지 목록을 불러오지 못했어요.'
|
||||
customItemReplacementItems.value = []
|
||||
} finally {
|
||||
customItemReplacementLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCustomItemModal(item) {
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = item?.label || ''
|
||||
customItemReplacementItems.value = []
|
||||
customItemReplacementTargetId.value = ''
|
||||
customItemReplacementBusy.value = false
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
void refreshReplacementCandidates()
|
||||
}
|
||||
|
||||
function closeCustomItemModal({ fromPopState = false } = {}) {
|
||||
@@ -71,6 +105,11 @@ export function useAdminCustomItems({
|
||||
customItemModalDraftLabel.value = ''
|
||||
customItemModalLabelSaving.value = false
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = ''
|
||||
customItemReplacementItems.value = []
|
||||
customItemReplacementTargetId.value = ''
|
||||
customItemReplacementLoading.value = false
|
||||
customItemReplacementBusy.value = false
|
||||
|
||||
if (fromPopState) {
|
||||
customItemModalHistoryActive.value = false
|
||||
@@ -190,6 +229,35 @@ export function useAdminCustomItems({
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
|
||||
if (!item?.id) {
|
||||
error.value = '대체할 원본 아이템을 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
if (!targetItem?.id) {
|
||||
error.value = '대체할 대상 이미지를 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
customItemReplacementBusy.value = true
|
||||
const data = await api.replaceAdminCustomItem(item.id, {
|
||||
targetItemId: targetItem.id,
|
||||
targetSourceType: targetItem.sourceType || 'user',
|
||||
})
|
||||
if (selectedTemplateId.value) await loadTemplate()
|
||||
await refreshCustomItems()
|
||||
closeCustomItemModal()
|
||||
success.value = `"${item.label}" 이미지를 "${data.targetItem?.label || targetItem.label}" 기준으로 대체했어요.`
|
||||
} catch (e) {
|
||||
error.value = e?.status === 409 ? '같은 이미지/이름으로는 대체할 수 없어요.' : '이미지 대체에 실패했어요.'
|
||||
} finally {
|
||||
customItemReplacementBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submitCustomItemSearch,
|
||||
changeCustomItemFilter,
|
||||
@@ -205,5 +273,7 @@ export function useAdminCustomItems({
|
||||
removeUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
refreshReplacementCandidates,
|
||||
replaceCustomItem,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,8 @@ export const api = {
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminTemplateItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
replaceAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }),
|
||||
updateAdminCustomItemLabel: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/label`, { method: 'PATCH', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
|
||||
@@ -46,6 +46,11 @@ const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemFilter = ref('library')
|
||||
const customItemModalTargetTemplateId = ref('')
|
||||
const customItemReplacementQuery = ref('')
|
||||
const customItemReplacementItems = ref([])
|
||||
const customItemReplacementLoading = ref(false)
|
||||
const customItemReplacementTargetId = ref('')
|
||||
const customItemReplacementBusy = ref(false)
|
||||
|
||||
const adminTierLists = ref([])
|
||||
const adminTierListQuery = ref('')
|
||||
@@ -227,6 +232,9 @@ const filteredTemplatePickerTemplates = computed(() => {
|
||||
})
|
||||
})
|
||||
const customItemTargetTemplate = computed(() => templates.value.find((template) => template.id === customItemModalTargetTemplateId.value) || null)
|
||||
const customItemReplacementTarget = computed(
|
||||
() => customItemReplacementItems.value.find((item) => item.id === customItemReplacementTargetId.value) || null
|
||||
)
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
const activeTabTitle = computed(() => {
|
||||
if (activeTab.value === 'featured') return '목록 관리'
|
||||
@@ -647,6 +655,7 @@ const visibleLinkedTemplates = computed(() =>
|
||||
(modalTargetCustomItem.value?.linkedTemplates || []).filter((template) => template?.id && template.id !== 'freeform')
|
||||
)
|
||||
const linkedCustomItemTemplateIds = computed(() => new Set(visibleLinkedTemplates.value.map((template) => template.id).filter(Boolean)))
|
||||
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
||||
|
||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||
const imageStatsYearOptions = computed(() => {
|
||||
@@ -1041,6 +1050,8 @@ const {
|
||||
removeUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
refreshReplacementCandidates,
|
||||
replaceCustomItem,
|
||||
} = useAdminCustomItems({
|
||||
api,
|
||||
toast,
|
||||
@@ -1057,6 +1068,11 @@ const {
|
||||
customItemModalDraftLabel,
|
||||
customItemModalLabelSaving,
|
||||
customItemModalTargetTemplateId,
|
||||
customItemReplacementQuery,
|
||||
customItemReplacementItems,
|
||||
customItemReplacementLoading,
|
||||
customItemReplacementTargetId,
|
||||
customItemReplacementBusy,
|
||||
templates,
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
@@ -2079,6 +2095,49 @@ function openUserProfile(user) {
|
||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||
</div>
|
||||
<div class="customItemModal__pickerHead">
|
||||
<div class="customItemModal__pickerEyebrow">IMAGE REPLACEMENT</div>
|
||||
<div class="customItemModal__pickerTitle">대체할 이미지 선택</div>
|
||||
</div>
|
||||
<div class="adminSelectionCard">
|
||||
<div class="adminSelectionCard__label">선택한 대체 이미지</div>
|
||||
<div class="adminSelectionCard__title">{{ customItemReplacementTarget?.label || '아직 선택하지 않음' }}</div>
|
||||
<div class="adminSelectionCard__meta">{{ customItemReplacementTarget?.sourceLabel || '검색 후 대체할 이미지를 골라 주세요.' }}</div>
|
||||
</div>
|
||||
<div class="customItemModal__pickerActions">
|
||||
<input
|
||||
v-model="customItemReplacementQuery"
|
||||
class="input"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
placeholder="대체할 이미지 이름 또는 파일명 검색"
|
||||
@keydown.enter.prevent="refreshReplacementCandidates"
|
||||
/>
|
||||
<button class="btn btn--ghost btn--small" type="button" @click="refreshReplacementCandidates">
|
||||
{{ customItemReplacementLoading ? '검색중...' : '대체 이미지 검색' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="customItemModal__replacementList">
|
||||
<button
|
||||
v-for="item in customItemReplacementItems"
|
||||
:key="item.id"
|
||||
class="adminTemplatePicker__item"
|
||||
:class="{ 'adminTemplatePicker__item--active': customItemReplacementTargetId === item.id }"
|
||||
type="button"
|
||||
@click="customItemReplacementTargetId = item.id"
|
||||
>
|
||||
<span class="customItemModal__replacementRow">
|
||||
<img class="customItemModal__replacementThumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<span class="customItemModal__replacementCopy">
|
||||
<span class="adminTemplatePicker__name">{{ item.label }}</span>
|
||||
<span class="adminTemplatePicker__meta">{{ item.sourceLabel }} · {{ item.ownerName || '알 수 없음' }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!customItemReplacementLoading && !replacementCandidateCount" class="hint hint--tight">
|
||||
대체 후보가 없어요. 검색어를 바꾸거나 먼저 관리자 이미지를 등록해주세요.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="customItemModal__body">
|
||||
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
|
||||
@@ -2119,6 +2178,9 @@ function openUserProfile(user) {
|
||||
<button class="btn btn--ghost customItemModal__action" :disabled="!customItemModalTargetTemplateId || modalTargetCustomItem.isPromoting" @click="promoteCustomItem(modalTargetCustomItem)">
|
||||
{{ modalTargetCustomItem.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||
</button>
|
||||
<button class="btn btn--primary customItemModal__action" :disabled="!customItemReplacementTargetId || customItemReplacementBusy" @click="replaceCustomItem(modalTargetCustomItem)">
|
||||
{{ customItemReplacementBusy ? '대체중...' : '선택한 이미지로 대체' }}
|
||||
</button>
|
||||
<button v-if="modalTargetCustomItem.canDelete" class="btn btn--danger customItemModal__action" :disabled="modalTargetCustomItem.sourceType === 'user' && (modalTargetCustomItem.usageCount > 0 || visibleLinkedTemplates.length > 0)" @click="openCustomItemDeleteModal(modalTargetCustomItem)">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3611,6 +3673,29 @@ function openUserProfile(user) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminUiScope .customItemModal__replacementList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.adminUiScope .customItemModal__replacementRow {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.adminUiScope .customItemModal__replacementThumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
.adminUiScope .customItemModal__replacementCopy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.adminUiScope .customItemModal__createTemplateButton {
|
||||
justify-self: start;
|
||||
}
|
||||
@@ -3736,7 +3821,7 @@ function openUserProfile(user) {
|
||||
}
|
||||
.adminUiScope .customItemModal__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user