Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 426e7de177 |
@@ -201,6 +201,14 @@ function getUserAccountName(row) {
|
||||
return email.split('@')[0] || email
|
||||
}
|
||||
|
||||
function getAssetLibrarySourceLabel(src) {
|
||||
const normalizedSrc = String(src || '').trim()
|
||||
if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타'
|
||||
if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일'
|
||||
if (normalizedSrc.includes('/uploads/assets/topics/')) return '템플릿 썸네일'
|
||||
return '보관 자산'
|
||||
}
|
||||
|
||||
async function createPool() {
|
||||
const rootConnection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
@@ -1857,8 +1865,8 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
ownerEmail: '',
|
||||
usageCount: 0,
|
||||
linkedTemplates: [],
|
||||
sourceType: 'template',
|
||||
sourceLabel: '관리자 템플릿',
|
||||
sourceType: 'asset',
|
||||
sourceLabel: getAssetLibrarySourceLabel(row.src),
|
||||
canDelete: true,
|
||||
sourceTopicId: '',
|
||||
sourceTopicName: '',
|
||||
@@ -1900,7 +1908,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
|
||||
siblings.forEach((entry) => {
|
||||
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else templateReferenceCount += 1
|
||||
;(entry.linkedTemplates || []).forEach((template) => {
|
||||
if (template?.id) linkedTemplates.set(template.id, template)
|
||||
@@ -1939,11 +1947,13 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
case 'template':
|
||||
return item.sourceType === 'template' && !item.isAssetLibraryItem
|
||||
case 'asset':
|
||||
return !!item.isAssetLibraryItem
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
case 'library':
|
||||
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
|
||||
case 'unused-user':
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
|
||||
case 'unused-admin':
|
||||
return !!item.isAssetLibraryItem
|
||||
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
|
||||
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sourceType: z.enum(['template', 'user']).optional().default('user'),
|
||||
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -332,7 +332,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
|
||||
filter: z.enum(['library', 'all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('library'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -672,6 +672,15 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
if (!target) return res.status(404).json({ error: 'not_found' })
|
||||
if (target.sourceType === 'asset' || String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
const asset = await findImageAssetById(assetId)
|
||||
if (!asset) return res.status(404).json({ error: 'not_found' })
|
||||
await deleteImageAssets([assetId])
|
||||
await removeUploadFiles([asset.src])
|
||||
return res.json({ ok: true, sourceType: 'asset' })
|
||||
}
|
||||
|
||||
if (target.sourceType === 'template') {
|
||||
if (String(target.id || '').startsWith('asset:')) {
|
||||
const assetId = String(target.id).slice('asset:'.length)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
|
||||
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동`과 `마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
|
||||
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `사용자 업로드 + 관리자 템플릿 아이템`만 먼저 보여주고, `filter=asset` / `filter=unused-admin`으로는 썸네일·프로필 이미지 같은 보관 자산만 따로 조회한다.
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
- `DELETE /api/admin/custom-items`
|
||||
@@ -220,6 +221,8 @@
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 아이템 관리 기본 필터는 `아이템만 (템플릿+사용자)`이며, 템플릿 기본 아이템과 사용자 업로드 아이템을 먼저 보여주고 프로필 아바타/티어표 썸네일/템플릿 썸네일 같은 보관 이미지 자산은 별도 필터로 분리한다.
|
||||
- `/uploads/assets/avatars/`는 `프로필 아바타`, `/uploads/assets/tierlists/`는 `티어표 썸네일`, `/uploads/assets/topics/`는 `템플릿 썸네일`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 실제 템플릿 기본 아이템만 `관리자 템플릿` 배지를 사용한다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
|
||||
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다.
|
||||
- `썸네일·프로필 이미지` 필터를 선택했을 때는 `/uploads/assets/avatars`, `/uploads/assets/tierlists`, `/uploads/assets/topics` 경로에 따라 각각 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일` 배지가 붙고, 일반 템플릿 기본 아이템만 `관리자 템플릿`으로 남는지 QA한다.
|
||||
- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하고, 미사용 썸네일·프로필 이미지 필터에서는 해당 자산만 따로 모아 보이는지 확인한다.
|
||||
- 아이템 관리 상단 통계의 `미사용 사용자 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 업로드 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다.
|
||||
- `v1.4.55`에서 회원 카드의 `최근 활동`을 `최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
|
||||
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
|
||||
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.56
|
||||
- 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다.
|
||||
- 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다.
|
||||
- 아이템 관리 필터 기본값을 `아이템만 (템플릿+사용자)`로 바꾸고, `썸네일·프로필 이미지`, `미사용 썸네일·프로필 이미지` 필터를 따로 제공해 기본 화면에서는 실제 아이템만 먼저 검수할 수 있게 했다.
|
||||
- 아이템 관리 상단의 `미사용` 통계가 프로필/썸네일 같은 자산까지 `usageCount=0`으로 같이 세면 잘못된 숫자처럼 보일 수 있으므로, `미사용 사용자 아이템`이라는 라벨로 바꾸고 실제 사용자 업로드 아이템 중 템플릿 연결과 사용 횟수가 모두 없는 항목만 세도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.55
|
||||
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
|
||||
- 백엔드 `users`에 `last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.
|
||||
|
||||
@@ -16,7 +16,15 @@ const props = defineProps({
|
||||
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
|
||||
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
|
||||
<span
|
||||
class="customItemCard__badge"
|
||||
:class="{
|
||||
'customItemCard__badge--template': item.sourceType === 'template',
|
||||
'customItemCard__badge--asset': item.sourceType === 'asset',
|
||||
}"
|
||||
>
|
||||
{{ item.sourceLabel }}
|
||||
</span>
|
||||
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
|
||||
</button>
|
||||
|
||||
@@ -119,9 +119,19 @@ export function useAdminCustomItems({
|
||||
closeCustomItemDeleteModal()
|
||||
closeCustomItemModal()
|
||||
await refreshCustomItems()
|
||||
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
|
||||
success.value =
|
||||
item.sourceType === 'template'
|
||||
? '선택한 템플릿 아이템을 제거했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '선택한 이미지 자산을 삭제했어요.'
|
||||
: '사용자 업로드 이미지를 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
error.value =
|
||||
item.sourceType === 'template'
|
||||
? '템플릿 아이템 제거에 실패했어요.'
|
||||
: item.sourceType === 'asset'
|
||||
? '이미지 자산 삭제에 실패했어요.'
|
||||
: '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const customItemQuery = ref('')
|
||||
const customItemPage = ref(1)
|
||||
const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemFilter = ref('all')
|
||||
const customItemFilter = ref('library')
|
||||
const customItemModalTargetTemplateId = ref('')
|
||||
|
||||
const adminTierLists = ref([])
|
||||
@@ -242,7 +242,12 @@ const activeTabDescription = computed(() => {
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const orphanItems = customItems.value.filter(
|
||||
(item) =>
|
||||
item.sourceType === 'user' &&
|
||||
Number(item.usageCount || 0) === 0 &&
|
||||
!(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)
|
||||
).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||
|
||||
if (activeTab.value === 'featured') {
|
||||
@@ -264,8 +269,9 @@ const adminOverviewStats = computed(() => {
|
||||
if (activeTab.value === 'items') {
|
||||
return [
|
||||
{ label: '검색 결과', value: `${customItemTotal.value}` },
|
||||
{ label: '미사용', value: `${orphanItems}` },
|
||||
{ label: '미사용 사용자 아이템', value: `${orphanItems}` },
|
||||
{ label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` },
|
||||
{ label: '이미지 자산', value: `${customItems.value.filter((item) => item.sourceType === 'asset').length}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
@@ -490,7 +496,7 @@ watch(
|
||||
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemFilter.value = 'all'
|
||||
customItemFilter.value = 'library'
|
||||
customItemPage.value = 1
|
||||
await refreshCustomItems()
|
||||
return
|
||||
@@ -599,10 +605,11 @@ function formatImageJobStatus(status) {
|
||||
|
||||
function customItemDeleteImpactText(item) {
|
||||
if (!item) return ''
|
||||
if (item.sourceType === 'asset' || item.isAssetLibraryItem) {
|
||||
return `"${item.label}" ${item.sourceLabel || '이미지 자산'} 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
}
|
||||
if (item.sourceType === 'template') {
|
||||
return item.isAssetLibraryItem
|
||||
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
return `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
}
|
||||
|
||||
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
@@ -747,7 +754,7 @@ function setTab(tab) {
|
||||
}
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemFilter.value = 'all'
|
||||
customItemFilter.value = 'library'
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
@@ -1389,7 +1396,7 @@ function buildModalItemFromTierListItem(item, tierList) {
|
||||
id,
|
||||
label: item?.label || matchedItem?.label || '이름 없음',
|
||||
src: item?.src || matchedItem?.src || '',
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
|
||||
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
|
||||
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
|
||||
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
|
||||
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
|
||||
@@ -2289,12 +2296,13 @@ function openUserProfile(user) {
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
|
||||
<option value="library">아이템만 (템플릿+사용자)</option>
|
||||
<option value="all">전체 이미지</option>
|
||||
<option value="user">사용자 업로드</option>
|
||||
<option value="template">템플릿 사용 이미지</option>
|
||||
<option value="asset">관리자 보관 자산</option>
|
||||
<option value="template">관리자 템플릿 아이템</option>
|
||||
<option value="asset">썸네일·프로필 이미지</option>
|
||||
<option value="unused-user">미사용 사용자 업로드</option>
|
||||
<option value="unused-admin">미사용 관리자 자산</option>
|
||||
<option value="unused-admin">미사용 썸네일·프로필 이미지</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
@@ -3480,6 +3488,9 @@ function openUserProfile(user) {
|
||||
.adminUiScope .customItemCard__badge--template {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.adminUiScope .customItemCard__badge--asset {
|
||||
background: rgba(251, 191, 36, 0.18);
|
||||
}
|
||||
.adminUiScope .customItemCard:hover {
|
||||
border-color: rgba(126, 162, 255, 0.42);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
||||
Reference in New Issue
Block a user