Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a16b1e1025 | |||
| c1dfea41a5 | |||
| 188576f8ac | |||
| 5db1e57f13 |
@@ -1042,6 +1042,160 @@ async function getReferencedUploadFootprint() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExistsForUploadSrc(src) {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true
|
||||
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
|
||||
try {
|
||||
await fs.stat(absolutePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function stripItemIdsFromGroups(groups, missingItemIds) {
|
||||
let changed = false
|
||||
const nextGroups = (groups || []).map((group) => {
|
||||
const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId))
|
||||
if (nextItemIds.length !== (group?.itemIds || []).length) changed = true
|
||||
return {
|
||||
...group,
|
||||
itemIds: nextItemIds,
|
||||
}
|
||||
})
|
||||
return { changed, groups: nextGroups }
|
||||
}
|
||||
|
||||
function stripMissingItems(items, missingItemIds, missingSrcs) {
|
||||
let changed = false
|
||||
const nextItems = (items || []).filter((item) => {
|
||||
const shouldRemove =
|
||||
(item?.id && missingItemIds.has(item.id)) ||
|
||||
(typeof item?.src === 'string' && missingSrcs.has(item.src))
|
||||
if (shouldRemove) changed = true
|
||||
return !shouldRemove
|
||||
})
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function cleanupMissingUploadReferences() {
|
||||
const stats = {
|
||||
clearedAvatars: 0,
|
||||
clearedGameThumbnails: 0,
|
||||
clearedTierListThumbnails: 0,
|
||||
clearedTemplateRequestThumbnails: 0,
|
||||
deletedGameItems: 0,
|
||||
updatedTierLists: 0,
|
||||
updatedTemplateRequests: 0,
|
||||
deletedCustomItems: 0,
|
||||
}
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT id, src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT id, src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
|
||||
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
|
||||
])
|
||||
|
||||
for (const row of userRows) {
|
||||
if (await fileExistsForUploadSrc(row.avatar_src)) continue
|
||||
await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id])
|
||||
stats.clearedAvatars += 1
|
||||
}
|
||||
|
||||
for (const row of gameRows) {
|
||||
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
|
||||
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id])
|
||||
stats.clearedGameThumbnails += 1
|
||||
}
|
||||
|
||||
for (const row of gameItemRows) {
|
||||
if (await fileExistsForUploadSrc(row.src)) continue
|
||||
await deleteGameItem(row.id)
|
||||
stats.deletedGameItems += 1
|
||||
}
|
||||
|
||||
const missingCustomItemIds = new Set()
|
||||
const missingCustomSrcs = new Set()
|
||||
for (const row of customItemRows) {
|
||||
if (await fileExistsForUploadSrc(row.src)) continue
|
||||
missingCustomItemIds.add(row.id)
|
||||
missingCustomSrcs.add(row.src)
|
||||
}
|
||||
|
||||
for (const row of tierListRows) {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const pool = parseJson(row.pool_json, [])
|
||||
let changed = false
|
||||
let nextThumbnail = row.thumbnail_src || ''
|
||||
|
||||
if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) {
|
||||
nextThumbnail = ''
|
||||
changed = true
|
||||
stats.clearedTierListThumbnails += 1
|
||||
}
|
||||
|
||||
const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs)
|
||||
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
|
||||
if (strippedPool.changed || strippedGroups.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||
nextThumbnail,
|
||||
serializeJson(strippedGroups.groups),
|
||||
serializeJson(strippedPool.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
stats.updatedTierLists += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of templateRequestRows) {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const items = parseJson(row.items_json, [])
|
||||
const boardItems = parseJson(row.board_items_json, [])
|
||||
let changed = false
|
||||
let nextThumbnail = row.thumbnail_src_snapshot || ''
|
||||
|
||||
if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) {
|
||||
nextThumbnail = ''
|
||||
changed = true
|
||||
stats.clearedTemplateRequestThumbnails += 1
|
||||
}
|
||||
|
||||
const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs)
|
||||
const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs)
|
||||
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
|
||||
if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query(
|
||||
'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?',
|
||||
[
|
||||
nextThumbnail,
|
||||
serializeJson(strippedGroups.groups),
|
||||
serializeJson(strippedItems.items),
|
||||
serializeJson(strippedBoardItems.items),
|
||||
now(),
|
||||
row.id,
|
||||
]
|
||||
)
|
||||
stats.updatedTemplateRequests += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (missingCustomItemIds.size) {
|
||||
await deleteCustomItems(Array.from(missingCustomItemIds))
|
||||
stats.deletedCustomItems = missingCustomItemIds.size
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
async function getImageAssetStats({ month } = {}) {
|
||||
const range = resolveMonthRange(month)
|
||||
const jobWhere = []
|
||||
@@ -1444,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':
|
||||
@@ -2188,6 +2391,7 @@ module.exports = {
|
||||
replaceUploadSourceReferences,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
cleanupMissingUploadReferences,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateGameItemDisplayOrder,
|
||||
|
||||
@@ -44,6 +44,7 @@ const {
|
||||
getImageAssetStats,
|
||||
listRecentImageOptimizationJobs,
|
||||
clearImageOptimizationJobs,
|
||||
cleanupMissingUploadReferences,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
@@ -386,6 +387,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
|
||||
const result = await cleanupMissingUploadReferences()
|
||||
res.json({ result })
|
||||
})
|
||||
|
||||
async function removeUploadFiles(srcs) {
|
||||
await Promise.all(
|
||||
(srcs || []).map(async (src) => {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
|
||||
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
|
||||
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
||||
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
||||
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
||||
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
||||
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함.
|
||||
- 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함.
|
||||
- 현재 선택한 이미지 요약 카드에는 별도 배경과 테두리를 추가해, 기존 클릭 모달의 “선택 대상”과 오른쪽 작업 영역이 한눈에 구분되도록 시각 계층을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
|
||||
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
|
||||
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
|
||||
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
|
||||
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
||||
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const api = {
|
||||
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
||||
},
|
||||
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
||||
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
|
||||
@@ -90,6 +90,7 @@ const imageRecentJobs = ref([])
|
||||
const imageStatsMonth = ref('')
|
||||
const imageStatsLimit = ref(12)
|
||||
const imageResetModalOpen = ref(false)
|
||||
const imageMissingCleanupBusy = ref(false)
|
||||
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
@@ -556,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 []
|
||||
@@ -571,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))
|
||||
@@ -667,6 +684,30 @@ async function confirmImageReset() {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupMissingImageReferences() {
|
||||
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 게임/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
imageMissingCleanupBusy.value = true
|
||||
const data = await api.cleanupAdminMissingImageReferences()
|
||||
await Promise.all([refreshImageDiagnostics(), refreshGames(), refreshCustomItems(), refreshTemplateRequests()])
|
||||
const result = data.result || {}
|
||||
success.value =
|
||||
`누락 참조를 정리했어요. ` +
|
||||
`아바타 ${result.clearedAvatars || 0}건, ` +
|
||||
`게임 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
||||
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
|
||||
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
|
||||
`게임 아이템 ${result.deletedGameItems || 0}건, ` +
|
||||
`커스텀 아이템 ${result.deletedCustomItems || 0}건`
|
||||
} catch (e) {
|
||||
error.value = '누락 이미지 참조 정리에 실패했어요.'
|
||||
} finally {
|
||||
imageMissingCleanupBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
resetMessages()
|
||||
const nextRouteName = adminRouteNameByTab[tab]
|
||||
@@ -1220,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,
|
||||
@@ -1744,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>
|
||||
@@ -1783,7 +1842,6 @@ function userAvatarFallback(user) {
|
||||
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
|
||||
<div class="customItemModal__labelEditor">
|
||||
<label class="field">
|
||||
<span class="field__label">아이템 이름</span>
|
||||
@@ -1805,6 +1863,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>
|
||||
@@ -1822,7 +1899,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>
|
||||
@@ -2065,6 +2142,11 @@ function userAvatarFallback(user) {
|
||||
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
||||
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--danger" :disabled="!imageStats?.missingReferencedCount || imageMissingCleanupBusy" @click="cleanupMissingImageReferences">
|
||||
{{ imageMissingCleanupBusy ? '누락 참조 정리중...' : '누락 참조 정리' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
|
||||
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
||||
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
||||
@@ -2736,8 +2818,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);
|
||||
@@ -3112,6 +3195,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);
|
||||
@@ -3123,14 +3215,46 @@ function userAvatarFallback(user) {
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 28px 22px;
|
||||
border-right: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerHead {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminUiScope .customItemModal__selected {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
.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;
|
||||
@@ -3188,6 +3312,7 @@ function userAvatarFallback(user) {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 24px 28px 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.adminUiScope .customItemModal__content {
|
||||
min-width: 0;
|
||||
@@ -3196,14 +3321,24 @@ function userAvatarFallback(user) {
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
overflow: auto;
|
||||
padding-right: 0;
|
||||
padding-right: 8px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar,
|
||||
.adminUiScope .customItemModal__content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar-thumb,
|
||||
.adminUiScope .customItemModal__content::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar-track,
|
||||
.adminUiScope .customItemModal__content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.adminUiScope .customItemModal__labelEditor {
|
||||
display: flex;
|
||||
@@ -3224,6 +3359,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;
|
||||
@@ -3232,15 +3386,6 @@ function userAvatarFallback(user) {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.adminUiScope .customItemModal__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: min(360px, 34dvh);
|
||||
object-fit: cover;
|
||||
border-radius: 24px;
|
||||
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
.adminUiScope .customItemModal__label {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-faint);
|
||||
|
||||
Reference in New Issue
Block a user