릴리스: v1.3.67 아이템 참조 요약과 영향 표시

This commit is contained in:
2026-04-02 15:04:20 +09:00
parent 188576f8ac
commit c1dfea41a5
6 changed files with 173 additions and 3 deletions

View File

@@ -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':

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-04-02 v1.3.67
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
## 2026-04-02 v1.3.66
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.

View File

@@ -8,6 +8,7 @@
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.

View File

@@ -1,5 +1,10 @@
# 업데이트 로그
## 2026-04-02 v1.3.67
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
## 2026-04-02 v1.3.66
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.

View File

@@ -19,6 +19,10 @@ const props = defineProps({
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__stats">
<span class="customItemCard__stat">참조 {{ item.sharedReferenceCount || 1 }}</span>
<span class="customItemCard__stat">게임 {{ item.sharedLinkedGameCount || item.linkedGames?.length || 0 }}</span>
</div>
</button>
</div>

View File

@@ -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) {
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div v-if="modalTargetCustomItem" class="customItemModal">
<aside class="customItemModal__pickerPanel">
<div class="customItemModal__selected">
<img class="customItemModal__selectedImage" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
<div class="customItemModal__selectedMeta">
<div class="customItemModal__selectedTitle">{{ modalTargetCustomItem.label }}</div>
<div class="customItemModal__selectedChips">
<span class="pill">{{ modalTargetCustomItem.sharedReferenceCount || 1 }} 참조</span>
<span class="pill" v-if="modalTargetCustomItem.sharedUserReferenceCount">사용자 {{ modalTargetCustomItem.sharedUserReferenceCount }}</span>
<span class="pill" v-if="modalTargetCustomItem.sharedTemplateReferenceCount">템플릿 {{ modalTargetCustomItem.sharedTemplateReferenceCount }}</span>
<span class="pill" v-if="modalTargetCustomItem.sharedAssetReferenceCount">보관 {{ modalTargetCustomItem.sharedAssetReferenceCount }}</span>
</div>
</div>
</div>
<div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
@@ -1830,6 +1864,25 @@ function userAvatarFallback(user) {
<button v-for="game in visibleLinkedGames" :key="game.id" type="button" class="pill pill--link" @click="jumpToGameAdmin(game.id)">{{ game.name }}</button>
</div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">같은 이미지 참조</span>
<div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span> 참조</span><strong>{{ modalTargetCustomItem.sharedReferenceCount || 1 }}</strong></div>
<div class="customItemModal__metaRow"><span>사용자 업로드</span><strong>{{ modalTargetCustomItem.sharedUserReferenceCount || 0 }}</strong></div>
<div class="customItemModal__metaRow"><span>템플릿 항목</span><strong>{{ modalTargetCustomItem.sharedTemplateReferenceCount || 0 }}</strong></div>
<div class="customItemModal__metaRow"><span>보관 자산</span><strong>{{ modalTargetCustomItem.sharedAssetReferenceCount || 0 }}</strong></div>
</div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">같은 이미지 기록</span>
<div v-if="visibleSharedEntries.length" class="customItemModal__entryList">
<div v-for="entry in visibleSharedEntries" :key="entry.id" class="customItemModal__entry">
<div class="customItemModal__entryTitle">{{ entry.label }}</div>
<div class="customItemModal__entryMeta">{{ entry.sourceLabel }} · {{ entry.ownerName || '알 수 없음' }}</div>
</div>
</div>
<div v-else class="hint hint--tight">같은 이미지를 가리키는 다른 기록이 없어요.</div>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
@@ -1847,7 +1900,7 @@ function userAvatarFallback(user) {
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">아이템 삭제</div>
<div class="modalCard__desc">{{ !modalTargetCustomItem ? '' : modalTargetCustomItem.sourceType === 'template' ? '"' + modalTargetCustomItem.label + '" 항목을 정리할까요? 게임에 연결된 항목이면 해당 템플릿과 저장된 같은 게임의 티어표에서도 함께 빠질 수 있고, 보관 자산이면 라이브러리에서만 제거됩니다.' : '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 사용자 업로드이면서 어디에도 연결되지 않은 이미지에만 삭제를 허용합니다.' }}</div>
<div class="modalCard__desc">{{ customItemDeleteImpactText(modalTargetCustomItem) }}</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
@@ -2766,8 +2819,9 @@ function userAvatarFallback(user) {
}
.adminUiScope .gameSettingsCard__actions {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
/* flex-wrap: wrap; */
}
.adminUiScope .selectedThumb {
width: min(100%, 256px);
@@ -3142,6 +3196,15 @@ function userAvatarFallback(user) {
line-height: 1.3;
color: var(--theme-text);
}
.adminUiScope .customItemCard__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adminUiScope .customItemCard__stat {
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
@@ -3161,6 +3224,31 @@ function userAvatarFallback(user) {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__selected {
display: grid;
gap: 12px;
}
.adminUiScope .customItemModal__selectedImage {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 18px;
object-fit: cover;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__selectedMeta {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__selectedTitle {
font-size: 18px;
font-weight: 900;
}
.adminUiScope .customItemModal__selectedChips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adminUiScope .customItemModal__pickerEyebrow {
font-size: 11px;
letter-spacing: 0.12em;
@@ -3254,6 +3342,25 @@ function userAvatarFallback(user) {
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__entryList {
display: grid;
gap: 8px;
}
.adminUiScope .customItemModal__entry {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__entryTitle {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .customItemModal__entryMeta {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal__close {
justify-self: end;
border: 0;