diff --git a/backend/src/db.js b/backend/src/db.js index 1f4dcf4..482aba2 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1598,7 +1598,56 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod sourceGameName: row.game_name || row.game_id, })) - const allItems = [...customItems, ...templateItems, ...assetLibraryItems] + const baseItems = [...customItems, ...templateItems, ...assetLibraryItems] + const groupedBySrc = new Map() + for (const item of baseItems) { + if (!item?.src) continue + if (!groupedBySrc.has(item.src)) groupedBySrc.set(item.src, []) + groupedBySrc.get(item.src).push(item) + } + + const allItems = baseItems + .map((item) => { + const siblings = groupedBySrc.get(item.src) || [item] + const linkedGames = new Map() + let userReferenceCount = 0 + let templateReferenceCount = 0 + let assetReferenceCount = 0 + + siblings.forEach((entry) => { + if (entry.sourceType === 'user') userReferenceCount += 1 + else if (entry.isAssetLibraryItem) assetReferenceCount += 1 + else templateReferenceCount += 1 + ;(entry.linkedGames || []).forEach((game) => { + if (game?.id) linkedGames.set(game.id, game) + }) + }) + + return { + ...item, + sharedReferenceCount: siblings.length, + sharedUserReferenceCount: userReferenceCount, + sharedTemplateReferenceCount: templateReferenceCount, + sharedAssetReferenceCount: assetReferenceCount, + sharedLinkedGameCount: linkedGames.size, + sharedEntries: siblings + .slice() + .sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)) + .map((entry) => ({ + id: entry.id, + label: entry.label, + sourceLabel: entry.sourceLabel, + sourceType: entry.sourceType, + ownerName: entry.ownerName, + createdAt: entry.createdAt, + sourceGameId: entry.sourceGameId || '', + sourceGameName: entry.sourceGameName || '', + usageCount: entry.usageCount || 0, + linkedGames: entry.linkedGames || [], + isAssetLibraryItem: !!entry.isAssetLibraryItem, + })), + } + }) .filter((item) => { switch (filterMode) { case 'user': diff --git a/docs/history.md b/docs/history.md index 6546e36..3d259ee 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.3.67 +- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다. +- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다. + ## 2026-04-02 v1.3.66 - 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다. diff --git a/docs/todo.md b/docs/todo.md index ee89e73..51947a7 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -8,6 +8,7 @@ - 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다. - 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다. - `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다. +- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다. ## 중기 개선 - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. diff --git a/docs/update.md b/docs/update.md index dbf0f76..bb1210e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.3.67 +- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함. +- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함. +- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함. + ## 2026-04-02 v1.3.66 - `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함. - 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함. diff --git a/frontend/src/components/admin/AdminItemsSection.vue b/frontend/src/components/admin/AdminItemsSection.vue index ae9ae47..518081c 100644 --- a/frontend/src/components/admin/AdminItemsSection.vue +++ b/frontend/src/components/admin/AdminItemsSection.vue @@ -19,6 +19,10 @@ const props = defineProps({ {{ item.sourceLabel }}
{{ item.label }}
+
+ 참조 {{ item.sharedReferenceCount || 1 }} + 게임 {{ item.sharedLinkedGameCount || item.linkedGames?.length || 0 }} +
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index c83f2c5..85230e8 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -557,6 +557,21 @@ function formatImageJobStatus(status) { } } +function customItemDeleteImpactText(item) { + if (!item) return '' + const sharedCount = Number(item.sharedReferenceCount || 1) + + if (item.sourceType === 'template') { + const base = item.isAssetLibraryItem + ? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.` + : `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.` + return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base + } + + const base = `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.` + return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base +} + const imageDiagnosticsCards = computed(() => { const stats = imageStats.value if (!stats) return [] @@ -572,6 +587,7 @@ const imageDiagnosticsCards = computed(() => { const visibleLinkedGames = computed(() => (modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform') ) +const visibleSharedEntries = computed(() => modalTargetCustomItem.value?.sharedEntries || []) const filteredCustomItemModalGames = computed(() => { const query = customItemModalGameQuery.value.trim().toLowerCase() const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id)) @@ -1245,6 +1261,12 @@ function buildModalItemFromTierListItem(item, tierList) { sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템', ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList), linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [], + sharedReferenceCount: matchedItem?.sharedReferenceCount || 1, + sharedUserReferenceCount: matchedItem?.sharedUserReferenceCount || 0, + sharedTemplateReferenceCount: matchedItem?.sharedTemplateReferenceCount || 0, + sharedAssetReferenceCount: matchedItem?.sharedAssetReferenceCount || 0, + sharedLinkedGameCount: matchedItem?.sharedLinkedGameCount || 0, + sharedEntries: Array.isArray(matchedItem?.sharedEntries) ? matchedItem.sharedEntries : [], usageCount: matchedItem?.usageCount || 0, canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false, isPromoting: false, @@ -1769,6 +1791,18 @@ function userAvatarFallback(user) {