fix: 관리자 이미지 대체 흐름 보정
This commit is contained in:
@@ -822,6 +822,7 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||
|
||||
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
|
||||
if (!sourceItem?.src) return res.status(404).json({ error: 'source_not_found' })
|
||||
if (sourceItem.sourceType !== 'user') return res.status(400).json({ error: 'user_item_required' })
|
||||
|
||||
const targetItem = await findLibraryItemForReplacement(parsed.data.targetItemId, parsed.data.targetSourceType)
|
||||
if (!targetItem?.src) return res.status(404).json({ error: 'target_not_found' })
|
||||
@@ -835,6 +836,10 @@ router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||
toLabel: targetItem.label || '',
|
||||
})
|
||||
|
||||
const sourceCustomItems = await findCustomItemsByIds([sourceItem.id])
|
||||
await deleteCustomItems([sourceItem.id])
|
||||
await removeCustomItemFiles(sourceCustomItems)
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
updatedRows: result.updatedRows || 0,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-06 v1.4.79
|
||||
- 관리자 대체 모달은 열자마자 현재 라이브러리 결과를 그대로 쏟아내면 “같은 보드 안의 비슷한 항목을 고르는 화면”처럼 읽히기 쉬우므로, 검색 전에는 후보를 비워 두고 운영자가 의도적으로 찾은 뒤 고르는 방식이 더 분명하다고 판단했다.
|
||||
- 또 사용자 업로드 A를 F로 대체했을 때 관리자 목록에 F가 두 장 보이면 “참조 이동”보다 “복제 생성”처럼 느껴지므로, 사용자 업로드 대체는 참조를 옮긴 뒤 원본 레코드 자체를 정리해 결과적으로 목표 이미지 한 장만 남는 쪽이 운영 기대와 더 잘 맞는다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.78
|
||||
- 사용자 업로드 이미지의 “같은 캐릭터인데 파일만 다른 경우”는 자동 판별하려 들수록 오탐 위험이 커지므로, 관리자 모달에서 대상 이미지를 직접 검색·선택하는 수동 치환 흐름으로 시작하는 편이 가장 안전하다고 판단했다.
|
||||
- 이때 `src`만 바꾸고 기존 라벨을 남기면 운영자가 통합한 뒤에도 표기가 제각각 남을 수 있으므로, 치환 대상의 `라벨`을 기준으로 사용자 업로드 행과 저장된 티어표/요청 스냅샷 내부 라벨까지 함께 맞춰 주는 편이 운영 목적에 더 부합한다고 정리했다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-06 v1.4.79
|
||||
- 관리자 이미지 대체 모달은 처음 열었을 때 기존 목록을 자동으로 보여주지 않고, 검색을 실행한 뒤에만 대체 후보를 표시하도록 바꿨다. 같은 티어표/같은 문맥의 항목이 처음부터 섞여 보여 혼란스럽던 점을 줄이기 위한 조정이다.
|
||||
- 사용자 업로드 아이템을 다른 이미지로 대체할 때는 이제 원본 항목을 그대로 `대상 이미지와 라벨`로 덮어써 중복 항목을 남기지 않고, 참조를 옮긴 뒤 원본 사용자 아이템 레코드와 파일을 함께 정리해 관리자 라이브러리에 동일 이미지가 두 번 보이지 않도록 맞췄다.
|
||||
- 이미지 대체 기능은 현재 사용자 업로드 아이템에만 노출되도록 제한해, 템플릿 기본 아이템이나 보관 자산까지 같은 방식으로 다뤄 생길 수 있는 오해를 줄였다.
|
||||
- 확인: `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.78
|
||||
- 관리자 아이템 관리 모달에 `선택한 이미지로 대체` 기능을 추가했다. 운영자는 대체할 원본 아이템을 연 뒤, 모달 안에서 다른 라이브러리 이미지를 검색·선택해 수동으로 치환할 수 있다.
|
||||
- 이 치환은 단순히 `src`만 바꾸는 것이 아니라, 선택한 대상 이미지의 `라벨`도 함께 따라가도록 처리해 사용자 업로드 아이템과 티어표 저장 JSON 안의 표기가 같은 이름으로 정리되게 맞췄다.
|
||||
|
||||
@@ -89,13 +89,12 @@ export function useAdminCustomItems({
|
||||
modalTargetCustomItem.value = item || null
|
||||
customItemModalDraftLabel.value = item?.label || ''
|
||||
customItemModalTargetTemplateId.value = ''
|
||||
customItemReplacementQuery.value = item?.label || ''
|
||||
customItemReplacementQuery.value = ''
|
||||
customItemReplacementItems.value = []
|
||||
customItemReplacementTargetId.value = ''
|
||||
customItemReplacementBusy.value = false
|
||||
customItemModalOpen.value = true
|
||||
pushCustomItemModalHistoryState()
|
||||
void refreshReplacementCandidates()
|
||||
}
|
||||
|
||||
function closeCustomItemModal({ fromPopState = false } = {}) {
|
||||
|
||||
@@ -655,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 canReplaceModalTarget = computed(() => modalTargetCustomItem.value?.sourceType === 'user')
|
||||
const replacementCandidateCount = computed(() => customItemReplacementItems.value.length)
|
||||
|
||||
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
|
||||
@@ -2095,48 +2096,53 @@ 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">
|
||||
대체 후보가 없어요. 검색어를 바꾸거나 먼저 관리자 이미지를 등록해주세요.
|
||||
<template v-if="canReplaceModalTarget">
|
||||
<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>
|
||||
</template>
|
||||
<div v-else class="hint hint--tight">
|
||||
이미지 대체는 현재 사용자 업로드 아이템에서만 지원합니다.
|
||||
</div>
|
||||
</aside>
|
||||
<div class="customItemModal__body">
|
||||
@@ -2178,7 +2184,7 @@ 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)">
|
||||
<button v-if="canReplaceModalTarget" 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>
|
||||
|
||||
Reference in New Issue
Block a user