일반 태그 배지 목록 정리
This commit is contained in:
@@ -11,6 +11,10 @@ const props = defineProps({
|
|||||||
saving: {
|
saving: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
defaultTagType: {
|
||||||
|
type: String,
|
||||||
|
default: 'general'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,7 +68,7 @@ const submitTag = () => {
|
|||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||||
color: form.color,
|
color: form.color,
|
||||||
tagType: props.initialTag.tagType || 'general'
|
tagType: props.initialTag.tagType || props.defaultTagType
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.1.6
|
||||||
|
|
||||||
|
- 관리자 태그 관리 화면에서 일반 태그도 검색 없이 배지형 전체 목록으로 확인하고, 최근 사용순·많이 사용순·이름순으로 정렬할 수 있도록 수정.
|
||||||
|
|
||||||
## v1.1.5
|
## v1.1.5
|
||||||
|
|
||||||
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
- 운영 Docker에서 새로 업로드한 로고·게시물 이미지·회원 썸네일이 재시작 전에도 바로 표시되도록 업로드 파일 제공 방식을 보강.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-15 v1.1.6
|
||||||
|
|
||||||
|
### 일반 태그도 검색 없이 보이는 관리 화면
|
||||||
|
|
||||||
|
메인 태그는 공개 카테고리 노출용이고 일반 태그는 게시물 분류 보조용이므로, 새 태그를 무조건 메인 태그로 올리면 공개 노출 의도가 섞인다. 다만 일반 태그를 검색해야만 볼 수 있으면 방금 만든 태그가 저장되지 않은 것처럼 느껴진다. 따라서 새 태그는 일반 태그 기본값을 유지하되, 관리자 태그 화면에서 일반 태그 전체 목록을 배지 형태로 항상 보여주고 필요할 때 메인 태그로 전환하도록 정리했다. 배지는 최근 사용순을 기본으로 하되 운영 판단에 따라 많이 사용순·이름순으로 바꿀 수 있게 했다.
|
||||||
|
|
||||||
## 2026-05-15 v1.1.5
|
## 2026-05-15 v1.1.5
|
||||||
|
|
||||||
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
### 운영 업로드 파일을 런타임 볼륨에서 직접 제공
|
||||||
|
|||||||
@@ -443,11 +443,13 @@ components/content/
|
|||||||
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
|
||||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||||
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
|
||||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
|
||||||
|
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
|
||||||
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
> 메인 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||||
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
> 메인 태그 순서가 서버에서 불러온 순서와 같을 때는 `정렬 저장` 버튼이 비활성화된다.
|
||||||
|
> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
|
||||||
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
|
||||||
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그 삭제는 검색 결과에서만 수행한다.
|
> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
|
||||||
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
|
||||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.1.6
|
||||||
|
|
||||||
|
- 관리자 태그 관리 화면에서 일반 태그를 검색 전에도 전체 목록으로 확인할 수 있도록 수정.
|
||||||
|
- 일반 태그 목록을 배지형 flex-wrap UI로 표시하고, 최근 사용/수정 태그가 앞에 오도록 정렬.
|
||||||
|
- 일반 태그 배지 목록에 최근 사용순·많이 사용순·이름순 정렬 전환을 추가.
|
||||||
|
- 일반 태그 배지에서 이름·슬러그 로컬 필터, 메인 태그 전환, 삭제를 바로 수행하도록 정리.
|
||||||
|
- 관리자 태그 추가 화면에서 새 태그는 일반 태그로 유지하되, 생성 후 태그 관리 화면에서 누락처럼 보이지 않게 정리.
|
||||||
|
- 패키지 버전 `1.1.6`으로 갱신.
|
||||||
|
|
||||||
## v1.1.5
|
## v1.1.5
|
||||||
|
|
||||||
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
|
- 운영 빌드에서 런타임 업로드 파일을 `/app/public/uploads` 볼륨에서 직접 제공하는 `/uploads/**` 서버 라우트 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -5920,7 +5920,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -6920,7 +6920,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/impound": {
|
"node_modules/impound": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz",
|
||||||
"integrity": "sha512-5AUn+QE0UofqNHu5f2Skf6Svukdg4ehOIq8O0EtqIx4jta0CDZYBPqpIHt0zrlUTiFVYlLpeH39DoikXBjPKpA==",
|
"integrity": "sha512-5AUn+QE0UofqNHu5f2Skf6Svukdg4ehOIq8O0EtqIx4jta0CDZYBPqpIHt0zrlUTiFVYlLpeH39DoikXBjPKpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.1.5",
|
"version": "1.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -12,14 +12,47 @@ const deletingGeneralTagId = ref('')
|
|||||||
const toast = ref(null)
|
const toast = ref(null)
|
||||||
let toastTimer = null
|
let toastTimer = null
|
||||||
const generalTagQuery = ref('')
|
const generalTagQuery = ref('')
|
||||||
const generalTagSearchResults = ref([])
|
const generalTagSortMode = ref('recent')
|
||||||
const generalTagSearchLoading = ref(false)
|
|
||||||
|
|
||||||
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
||||||
|
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
|
||||||
|
const filteredGeneralTags = computed(() => {
|
||||||
|
const keyword = generalTagQuery.value.trim().toLowerCase()
|
||||||
|
const sortedTags = [...generalTags.value].sort((a, b) => {
|
||||||
|
if (generalTagSortMode.value === 'count') {
|
||||||
|
const countDiff = Number(b.postCount || 0) - Number(a.postCount || 0)
|
||||||
|
if (countDiff !== 0) {
|
||||||
|
return countDiff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generalTagSortMode.value === 'name') {
|
||||||
|
return a.name.localeCompare(b.name, 'ko')
|
||||||
|
}
|
||||||
|
|
||||||
|
const aTime = new Date(a.lastUsedAt || a.updatedAt || 0).getTime()
|
||||||
|
const bTime = new Date(b.lastUsedAt || b.updatedAt || 0).getTime()
|
||||||
|
|
||||||
|
if (aTime !== bTime) {
|
||||||
|
return bTime - aTime
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.name.localeCompare(b.name, 'ko')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return sortedTags
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedTags.filter((tag) =>
|
||||||
|
tag.name.toLowerCase().includes(keyword) ||
|
||||||
|
tag.slug.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */
|
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */
|
||||||
const baselineManagedTagIds = ref([])
|
const baselineManagedTagIds = ref([])
|
||||||
@@ -184,27 +217,16 @@ const saveManagedOrder = async () => {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const searchGeneralTags = async () => {
|
const searchGeneralTags = async () => {
|
||||||
const keyword = generalTagQuery.value.trim()
|
generalTagQuery.value = generalTagQuery.value.trim()
|
||||||
if (!keyword) {
|
}
|
||||||
generalTagSearchResults.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
generalTagSearchLoading.value = true
|
/**
|
||||||
|
* 일반 태그 정렬 기준을 변경한다.
|
||||||
try {
|
* @param {'recent'|'count'|'name'} mode - 정렬 기준
|
||||||
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
|
* @returns {void}
|
||||||
query: {
|
*/
|
||||||
tagType: 'general',
|
const setGeneralTagSortMode = (mode) => {
|
||||||
q: keyword,
|
generalTagSortMode.value = mode
|
||||||
limit: 30
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
|
|
||||||
} finally {
|
|
||||||
generalTagSearchLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,7 +254,6 @@ const promoteToMainTag = async (tag) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
await refreshTagsFromServer()
|
await refreshTagsFromServer()
|
||||||
await searchGeneralTags()
|
|
||||||
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
|
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
|
||||||
@@ -266,7 +287,6 @@ const demoteToGeneralTag = async (tag) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
await refreshTagsFromServer()
|
await refreshTagsFromServer()
|
||||||
await searchGeneralTags()
|
|
||||||
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
|
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
|
||||||
@@ -292,7 +312,6 @@ const deleteGeneralTag = async (tag) => {
|
|||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
await refreshTagsFromServer()
|
await refreshTagsFromServer()
|
||||||
await searchGeneralTags()
|
|
||||||
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
|
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
|
||||||
@@ -323,7 +342,7 @@ onBeforeUnmount(() => {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-xs text-muted">
|
<p class="mt-3 text-xs text-muted">
|
||||||
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 수 있습니다.
|
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 때 메인 태그로 전환할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
|
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
|
||||||
@@ -404,44 +423,65 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
<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">
|
<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>
|
||||||
<div class="space-y-3 bg-white p-4">
|
<div class="space-y-3 bg-white p-4">
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
<input
|
<input
|
||||||
v-model="generalTagQuery"
|
v-model="generalTagQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
|
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
|
||||||
placeholder="일반 태그 이름 또는 슬러그 검색"
|
placeholder="일반 태그 이름 또는 슬러그 필터"
|
||||||
@keydown.enter.prevent="searchGeneralTags"
|
@keydown.enter.prevent="searchGeneralTags"
|
||||||
>
|
>
|
||||||
<button
|
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||||
:disabled="promotingTagId === tag.id"
|
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||||
@click="promoteToMainTag(tag)"
|
@click="setGeneralTagSortMode('recent')"
|
||||||
>
|
>
|
||||||
{{ promotingTagId === tag.id ? '전환 중' : '메인 태그로 전환' }}
|
최근 사용순
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||||
|
:class="generalTagSortMode === 'count' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||||
|
@click="setGeneralTagSortMode('count')"
|
||||||
|
>
|
||||||
|
많이 사용순
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1.5 text-xs font-semibold"
|
||||||
|
:class="generalTagSortMode === 'name' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||||
|
@click="setGeneralTagSortMode('name')"
|
||||||
|
>
|
||||||
|
이름순
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredGeneralTags.length" class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="tag in filteredGeneralTags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] px-3 py-2 text-sm"
|
||||||
|
:title="tag.slug"
|
||||||
|
>
|
||||||
|
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
<span class="truncate font-semibold text-ink">{{ tag.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50"
|
||||||
|
:disabled="promotingTagId === tag.id"
|
||||||
|
@click="promoteToMainTag(tag)"
|
||||||
|
>
|
||||||
|
{{ promotingTagId === tag.id ? '전환 중' : '메인' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50"
|
||||||
:disabled="deletingGeneralTagId === tag.id"
|
:disabled="deletingGeneralTagId === tag.id"
|
||||||
@click="deleteGeneralTag(tag)"
|
@click="deleteGeneralTag(tag)"
|
||||||
>
|
>
|
||||||
@@ -449,8 +489,8 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
|
<p v-else class="text-sm text-muted">
|
||||||
검색 결과가 없습니다.
|
{{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ const mapTagRow = (row) => ({
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
sortOrder: row.sort_order,
|
sortOrder: row.sort_order,
|
||||||
color: row.color,
|
color: row.color,
|
||||||
tagType: row.tag_type || 'managed'
|
tagType: row.tag_type || 'managed',
|
||||||
|
postCount: Number(row.post_count || 0),
|
||||||
|
lastUsedAt: row.last_used_at ? row.last_used_at.toISOString() : null,
|
||||||
|
updatedAt: row.updated_at ? row.updated_at.toISOString() : null
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -572,7 +575,10 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
|
|||||||
if (!sql) {
|
if (!sql) {
|
||||||
const sampleTags = getSampleTags().map((tag) => ({
|
const sampleTags = getSampleTags().map((tag) => ({
|
||||||
...tag,
|
...tag,
|
||||||
tagType: 'managed'
|
tagType: 'managed',
|
||||||
|
postCount: 0,
|
||||||
|
lastUsedAt: null,
|
||||||
|
updatedAt: null
|
||||||
}))
|
}))
|
||||||
let filteredTags = sampleTags
|
let filteredTags = sampleTags
|
||||||
if (tagType) {
|
if (tagType) {
|
||||||
@@ -588,17 +594,25 @@ export const listTags = async ({ tagType, searchQuery = '', limit } = {}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = await sql`
|
const rows = await sql`
|
||||||
SELECT *
|
SELECT
|
||||||
|
tags.*,
|
||||||
|
COUNT(post_tags.post_id)::int AS post_count,
|
||||||
|
MAX(posts.updated_at) AS last_used_at
|
||||||
FROM tags
|
FROM tags
|
||||||
|
LEFT JOIN post_tags ON post_tags.tag_id = tags.id
|
||||||
|
LEFT JOIN posts ON posts.id = post_tags.post_id
|
||||||
WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null})
|
WHERE (${tagType || null}::text IS NULL OR tag_type = ${tagType || null})
|
||||||
AND (
|
AND (
|
||||||
${trimmedSearchQuery || null}::text IS NULL
|
${trimmedSearchQuery || null}::text IS NULL
|
||||||
OR strpos(lower(name), ${trimmedSearchQuery || ''}) > 0
|
OR strpos(lower(tags.name), ${trimmedSearchQuery || ''}) > 0
|
||||||
OR strpos(lower(slug), ${trimmedSearchQuery || ''}) > 0
|
OR strpos(lower(tags.slug), ${trimmedSearchQuery || ''}) > 0
|
||||||
)
|
)
|
||||||
|
GROUP BY tags.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
|
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
|
||||||
sort_order ASC,
|
sort_order ASC,
|
||||||
|
MAX(posts.updated_at) DESC NULLS LAST,
|
||||||
|
tags.updated_at DESC,
|
||||||
name ASC
|
name ASC
|
||||||
LIMIT ${resolvedLimit || 1000}
|
LIMIT ${resolvedLimit || 1000}
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user