태그 관리 화면을 메인/일반 전환 중심으로 단순화하고 삭제 동선을 재정리.

글쓰기 Post URL 슬러그는 한글 입력 시 발음 기반 영문 소문자로 자동 생성되도록 개선.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 18:50:40 +09:00
parent cdc16c72b2
commit bd71ca860c
9 changed files with 312 additions and 149 deletions

View File

@@ -3,19 +3,23 @@ definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const draggingTagId = ref('')
const dragOverTagId = ref('')
const savingOrder = ref(false)
const promotingTagId = ref('')
const demotingTagId = ref('')
const deletingGeneralTagId = ref('')
const errorMessage = ref('')
const infoMessage = ref('')
const generalTagQuery = ref('')
const generalTagSearchResults = ref([])
const generalTagSearchLoading = ref(false)
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
default: () => []
})
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
/**
* 관리용 태그 드래그 시작
@@ -112,13 +116,9 @@ const saveManagedOrder = async () => {
}
})
const managedTagMap = new Map(reordered.map((tag) => [tag.id, tag]))
tags.value = [
...reordered,
...generalTags.value.map((tag) => managedTagMap.get(tag.id) || tag)
]
tags.value = [...reordered]
await refresh()
infoMessage.value = '관리용 태그 순서가 저장되었습니다.'
infoMessage.value = '메인 태그 순서가 저장되었습니다.'
} catch (error) {
errorMessage.value = error?.data?.message || '정렬 순서를 저장하지 못했습니다.'
} finally {
@@ -127,16 +127,118 @@ const saveManagedOrder = async () => {
}
/**
* 태그 삭제
* @param {Object} tag - 삭제할 태그
* @returns {Promise<void>} 삭제 처리 결과
* 일반 태그 검색
* @returns {Promise<void>}
*/
const deleteTag = async (tag) => {
if (!confirm(`"${tag.name}" 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
const searchGeneralTags = async () => {
const keyword = generalTagQuery.value.trim()
if (!keyword) {
generalTagSearchResults.value = []
return
}
deletingId.value = tag.id
generalTagSearchLoading.value = true
errorMessage.value = ''
infoMessage.value = ''
try {
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
query: {
tagType: 'general',
q: keyword,
limit: 30
}
})
} catch (error) {
errorMessage.value = error?.data?.message || '일반 태그 검색에 실패했습니다.'
} finally {
generalTagSearchLoading.value = false
}
}
/**
* 일반 태그를 메인 태그로 전환한다.
* @param {Object} tag - 대상 태그
* @returns {Promise<void>}
*/
const promoteToMainTag = async (tag) => {
if (promotingTagId.value) {
return
}
promotingTagId.value = tag.id
errorMessage.value = ''
infoMessage.value = ''
try {
await $fetch(`/admin/api/tags/${tag.id}`, {
method: 'PUT',
body: {
name: tag.name,
slug: tag.slug,
description: tag.description || '',
sortOrder: (managedTags.value.length + 1) * 10,
color: tag.color || '#15171a',
tagType: 'managed'
}
})
await refresh()
await searchGeneralTags()
infoMessage.value = `"${tag.name}" 태그를 메인 태그로 전환했습니다.`
} catch (error) {
errorMessage.value = error?.data?.message || '메인 태그 전환에 실패했습니다.'
} finally {
promotingTagId.value = ''
}
}
/**
* 메인 태그를 일반 태그로 강등한다.
* @param {Object} tag - 대상 태그
* @returns {Promise<void>}
*/
const demoteToGeneralTag = async (tag) => {
if (demotingTagId.value) {
return
}
demotingTagId.value = tag.id
errorMessage.value = ''
infoMessage.value = ''
try {
await $fetch(`/admin/api/tags/${tag.id}`, {
method: 'PUT',
body: {
name: tag.name,
slug: tag.slug,
description: tag.description || '',
sortOrder: 0,
color: tag.color || '#15171a',
tagType: 'general'
}
})
await refresh()
await searchGeneralTags()
infoMessage.value = `"${tag.name}" 태그를 일반 태그로 변경했습니다.`
} catch (error) {
errorMessage.value = error?.data?.message || '일반 태그 변경에 실패했습니다.'
} finally {
demotingTagId.value = ''
}
}
/**
* 검색된 일반 태그를 삭제한다.
* @param {Object} tag - 대상 태그
* @returns {Promise<void>}
*/
const deleteGeneralTag = async (tag) => {
if (!confirm(`"${tag.name}" 일반 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
return
}
deletingGeneralTagId.value = tag.id
errorMessage.value = ''
infoMessage.value = ''
@@ -145,12 +247,15 @@ const deleteTag = async (tag) => {
method: 'DELETE'
})
await refresh()
await searchGeneralTags()
infoMessage.value = `"${tag.name}" 일반 태그를 삭제했습니다.`
} catch (error) {
errorMessage.value = error?.data?.message || '태그를 삭제하지 못했습니다.'
errorMessage.value = error?.data?.message || '일반 태그를 삭제하지 못했습니다.'
} finally {
deletingId.value = ''
deletingGeneralTagId.value = ''
}
}
</script>
<template>
@@ -169,7 +274,7 @@ const deleteTag = async (tag) => {
</NuxtLink>
</div>
<p class="mt-3 text-xs text-muted">
관리용 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 추후 배지형 노출 용도 사용됩니다.
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그 전환할 있습니다.
</p>
<p v-if="errorMessage" class="admin-tags__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@@ -181,7 +286,7 @@ const deleteTag = async (tag) => {
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
<div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">관리용 태그</p>
<p class="text-xs font-semibold uppercase text-muted">메인 태그</p>
<button
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
type="button"
@@ -238,12 +343,12 @@ const deleteTag = async (tag) => {
수정
</NuxtLink>
<button
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
type="button"
:disabled="deletingId === tag.id"
@click="deleteTag(tag)"
:disabled="demotingTagId === tag.id"
@click="demoteToGeneralTag(tag)"
>
{{ deletingId === tag.id ? '삭제 중' : '제' }}
{{ demotingTagId === tag.id ? '변경 중' : '제' }}
</button>
</div>
</td>
@@ -254,53 +359,55 @@ const deleteTag = async (tag) => {
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
<p class="text-xs font-semibold uppercase text-muted">일반 태그 검색</p>
</div>
<div class="space-y-3 bg-white p-4">
<div class="flex gap-2">
<input
v-model="generalTagQuery"
type="text"
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
placeholder="일반 태그 이름 또는 슬러그 검색"
@keydown.enter.prevent="searchGeneralTags"
>
<button
type="button"
class="h-10 rounded border border-line bg-white px-4 text-sm font-semibold disabled:opacity-50"
:disabled="generalTagSearchLoading"
@click="searchGeneralTags"
>
{{ generalTagSearchLoading ? '검색 중' : '검색' }}
</button>
</div>
<div v-if="generalTagSearchResults.length" class="divide-y divide-line rounded border border-line">
<div v-for="tag in generalTagSearchResults" :key="tag.id" class="flex items-center gap-3 px-3 py-2.5">
<span class="h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">{{ tag.name }}</p>
<p class="truncate text-xs text-muted">{{ tag.slug }}</p>
</div>
<button
type="button"
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인 태그로 전환' }}
</button>
<button
type="button"
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
:disabled="deletingGeneralTagId === tag.id"
@click="deleteGeneralTag(tag)"
>
{{ deletingGeneralTagId === tag.id ? '삭제 중' : '삭제' }}
</button>
</div>
</div>
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
검색 결과가 없습니다.
</p>
</div>
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-tags__cell px-4 py-3">색상</th>
<th class="admin-tags__cell px-4 py-3">이름</th>
<th class="admin-tags__cell px-4 py-3">슬러그</th>
<th class="admin-tags__cell px-4 py-3">설명</th>
<th class="admin-tags__cell px-4 py-3">관리</th>
</tr>
</thead>
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
<tr v-for="tag in generalTags" :key="tag.id" class="admin-tags__row">
<td class="admin-tags__cell px-4 py-4">
<span class="admin-tags__color flex items-center gap-2">
<span class="admin-tags__color-swatch h-5 w-2 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="admin-tags__color-code text-xs text-muted">{{ tag.color }}</span>
</span>
</td>
<td class="admin-tags__cell px-4 py-4 font-semibold">
{{ tag.name }}
</td>
<td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.slug }}
</td>
<td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.description || '-' }}
</td>
<td class="admin-tags__cell px-4 py-4">
<div class="admin-tags__actions flex gap-2">
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`">
수정
</NuxtLink>
<button
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deletingId === tag.id"
@click="deleteTag(tag)"
>
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">