멤버 필터와 썸네일 편집 개선
This commit is contained in:
@@ -4,24 +4,275 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const memberSearchQuery = ref('')
|
||||
const isFilterOpen = ref(false)
|
||||
const filterDrafts = ref([
|
||||
{
|
||||
id: 1,
|
||||
field: 'email',
|
||||
operator: 'contains',
|
||||
value: ''
|
||||
}
|
||||
])
|
||||
const activeFilters = ref([])
|
||||
let nextFilterId = 2
|
||||
|
||||
const { data: members } = await useFetch('/admin/api/members', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const query = memberSearchQuery.value.trim().toLowerCase()
|
||||
const dateFilterFields = ['lastSeenAt', 'createdAt']
|
||||
|
||||
if (!query) {
|
||||
return members.value
|
||||
const filterFieldOptions = [
|
||||
{ value: 'name', label: '이름' },
|
||||
{ value: 'email', label: '이메일' },
|
||||
{ value: 'label', label: '레이블' },
|
||||
{ value: 'status', label: '상태' },
|
||||
{ value: 'lastSeenAt', label: '최근 접속' },
|
||||
{ value: 'createdAt', label: '가입일' }
|
||||
]
|
||||
|
||||
const operatorOptionsByFieldType = {
|
||||
text: [
|
||||
{ value: 'contains', label: '포함' },
|
||||
{ value: 'notContains', label: '포함하지 않음' },
|
||||
{ value: 'is', label: '일치' },
|
||||
{ value: 'startsWith', label: '시작' },
|
||||
{ value: 'endsWith', label: '끝남' }
|
||||
],
|
||||
status: [
|
||||
{ value: 'is', label: '일치' },
|
||||
{ value: 'isNot', label: '일치하지 않음' }
|
||||
],
|
||||
date: [
|
||||
{ value: 'before', label: '이전' },
|
||||
{ value: 'after', label: '이후' },
|
||||
{ value: 'empty', label: '없음' },
|
||||
{ value: 'notEmpty', label: '있음' }
|
||||
]
|
||||
}
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ value: '활성', label: '활성' },
|
||||
{ value: '비활성', label: '비활성' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 필터 필드 타입을 반환한다.
|
||||
* @param {string} field - 필터 필드
|
||||
* @returns {'text'|'status'|'date'} 필드 타입
|
||||
*/
|
||||
const getFilterFieldType = (field) => {
|
||||
if (field === 'status') {
|
||||
return 'status'
|
||||
}
|
||||
|
||||
return members.value.filter((member) => [
|
||||
member.username,
|
||||
member.email,
|
||||
member.lastSeenIp,
|
||||
member.activityStatus
|
||||
].some((value) => String(value || '').toLowerCase().includes(query)))
|
||||
if (dateFilterFields.includes(field)) {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
return 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 필드에 맞는 연산자 목록을 반환한다.
|
||||
* @param {string} field - 필터 필드
|
||||
* @returns {Array<{ value: string, label: string }>} 연산자 목록
|
||||
*/
|
||||
const getFilterOperatorOptions = (field) => operatorOptionsByFieldType[getFilterFieldType(field)]
|
||||
|
||||
/**
|
||||
* 필터 대상 회원 값을 반환한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @param {string} field - 필터 필드
|
||||
* @returns {string} 필터 대상 값
|
||||
*/
|
||||
const getFilterMemberValue = (member, field) => {
|
||||
if (field === 'name') {
|
||||
return member.username || ''
|
||||
}
|
||||
|
||||
if (field === 'label') {
|
||||
return Array.isArray(member.labels) ? member.labels.join(', ') : ''
|
||||
}
|
||||
|
||||
if (field === 'status') {
|
||||
return member.activityStatus || '비활성'
|
||||
}
|
||||
|
||||
return member[field] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 필터 조건을 검사한다.
|
||||
* @param {string} source - 원본 값
|
||||
* @param {string} operator - 연산자
|
||||
* @param {string} target - 비교 값
|
||||
* @returns {boolean} 조건 충족 여부
|
||||
*/
|
||||
const matchesTextFilter = (source, operator, target) => {
|
||||
const sourceText = source.toLowerCase()
|
||||
const targetText = target.toLowerCase()
|
||||
|
||||
if (!targetText) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (operator === 'is') {
|
||||
return sourceText === targetText
|
||||
}
|
||||
|
||||
if (operator === 'notContains') {
|
||||
return !sourceText.includes(targetText)
|
||||
}
|
||||
|
||||
if (operator === 'startsWith') {
|
||||
return sourceText.startsWith(targetText)
|
||||
}
|
||||
|
||||
if (operator === 'endsWith') {
|
||||
return sourceText.endsWith(targetText)
|
||||
}
|
||||
|
||||
return sourceText.includes(targetText)
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 필터 조건을 검사한다.
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @param {string} operator - 연산자
|
||||
* @param {string} target - yyyy-mm-dd 비교 값
|
||||
* @returns {boolean} 조건 충족 여부
|
||||
*/
|
||||
const matchesDateFilter = (value, operator, target) => {
|
||||
if (operator === 'empty') {
|
||||
return !value
|
||||
}
|
||||
|
||||
if (operator === 'notEmpty') {
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
if (!value || !target) {
|
||||
return true
|
||||
}
|
||||
|
||||
const sourceTime = new Date(value).getTime()
|
||||
const targetTime = new Date(`${target}T00:00:00`).getTime()
|
||||
|
||||
if (Number.isNaN(sourceTime) || Number.isNaN(targetTime)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return operator === 'before'
|
||||
? sourceTime < targetTime
|
||||
: sourceTime >= targetTime
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원이 단일 필터 조건을 만족하는지 확인한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @param {Object} filter - 필터 조건
|
||||
* @returns {boolean} 조건 충족 여부
|
||||
*/
|
||||
const matchesMemberFilter = (member, filter) => {
|
||||
const fieldType = getFilterFieldType(filter.field)
|
||||
const source = String(getFilterMemberValue(member, filter.field) || '')
|
||||
|
||||
if (fieldType === 'date') {
|
||||
return matchesDateFilter(source, filter.operator, filter.value)
|
||||
}
|
||||
|
||||
if (fieldType === 'status') {
|
||||
return filter.operator === 'isNot'
|
||||
? source !== filter.value
|
||||
: source === filter.value
|
||||
}
|
||||
|
||||
return matchesTextFilter(source, filter.operator, filter.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건 필드를 변경한다.
|
||||
* @param {Object} filter - 필터 조건
|
||||
* @returns {void}
|
||||
*/
|
||||
const changeFilterField = (filter) => {
|
||||
const fieldType = getFilterFieldType(filter.field)
|
||||
filter.operator = getFilterOperatorOptions(filter.field)[0].value
|
||||
filter.value = fieldType === 'status' ? '활성' : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 필터 조건을 추가한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const addFilter = () => {
|
||||
filterDrafts.value.push({
|
||||
id: nextFilterId,
|
||||
field: 'email',
|
||||
operator: 'contains',
|
||||
value: ''
|
||||
})
|
||||
nextFilterId += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건을 삭제한다.
|
||||
* @param {number} id - 필터 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeFilter = (id) => {
|
||||
filterDrafts.value = filterDrafts.value.filter((filter) => filter.id !== id)
|
||||
|
||||
if (!filterDrafts.value.length) {
|
||||
addFilter()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터 조건을 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyFilters = () => {
|
||||
activeFilters.value = filterDrafts.value
|
||||
.filter((filter) => ['empty', 'notEmpty'].includes(filter.operator) || String(filter.value || '').trim())
|
||||
.map((filter) => ({ ...filter }))
|
||||
isFilterOpen.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 필터 조건을 초기화한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
filterDrafts.value = [
|
||||
{
|
||||
id: nextFilterId,
|
||||
field: 'email',
|
||||
operator: 'contains',
|
||||
value: ''
|
||||
}
|
||||
]
|
||||
nextFilterId += 1
|
||||
activeFilters.value = []
|
||||
}
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const query = memberSearchQuery.value.trim().toLowerCase()
|
||||
const searchedMembers = !query
|
||||
? members.value
|
||||
: members.value.filter((member) => [
|
||||
member.username,
|
||||
member.email,
|
||||
member.lastSeenIp,
|
||||
member.activityStatus
|
||||
].some((value) => String(value || '').toLowerCase().includes(query)))
|
||||
|
||||
if (!activeFilters.value.length) {
|
||||
return searchedMembers
|
||||
}
|
||||
|
||||
return searchedMembers.filter((member) => activeFilters.value.every((filter) => matchesMemberFilter(member, filter)))
|
||||
})
|
||||
|
||||
const memberCountLabel = computed(() => {
|
||||
@@ -29,6 +280,8 @@ const memberCountLabel = computed(() => {
|
||||
return `${count}명`
|
||||
})
|
||||
|
||||
const activeFilterCount = computed(() => activeFilters.value.length)
|
||||
|
||||
/**
|
||||
* 회원 상세 화면으로 이동한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
@@ -136,6 +389,20 @@ const formatRelativeTime = (value) => {
|
||||
aria-label="멤버 검색"
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
class="admin-members__filter inline-flex h-11 items-center justify-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2] hover:text-black"
|
||||
type="button"
|
||||
:aria-expanded="isFilterOpen"
|
||||
@click="isFilterOpen = !isFilterOpen"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path d="M6.5 13.502H12M4 9.004L14 9M1.688 4.502h14.624" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<span>필터</span>
|
||||
<span v-if="activeFilterCount" class="admin-members__filter-count rounded-full bg-[#15171a] px-1.5 py-0.5 text-[11px] leading-none text-white">
|
||||
{{ activeFilterCount }}
|
||||
</span>
|
||||
</button>
|
||||
<NuxtLink
|
||||
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
|
||||
to="/admin/members/new"
|
||||
@@ -145,6 +412,101 @@ const formatRelativeTime = (value) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="isFilterOpen" class="admin-members__filter-panel mt-2 rounded-lg border border-line bg-white p-6 shadow-xl">
|
||||
<h2 class="admin-members__filter-title text-2xl font-semibold tracking-[-0.01em] text-[#15171a]">
|
||||
필터 목록
|
||||
</h2>
|
||||
<div class="admin-members__filter-body mt-6 rounded-md bg-[#eef1f4] p-5">
|
||||
<div class="admin-members__filter-rules grid gap-3">
|
||||
<div
|
||||
v-for="filter in filterDrafts"
|
||||
:key="filter.id"
|
||||
class="admin-members__filter-row grid gap-2 lg:grid-cols-[minmax(0,1fr)_220px_minmax(0,1fr)_36px]"
|
||||
>
|
||||
<label class="admin-members__filter-field">
|
||||
<span class="sr-only">필터 항목</span>
|
||||
<select
|
||||
v-model="filter.field"
|
||||
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
@change="changeFilterField(filter)"
|
||||
>
|
||||
<option v-for="option in filterFieldOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-members__filter-operator">
|
||||
<span class="sr-only">필터 조건</span>
|
||||
<select
|
||||
v-model="filter.operator"
|
||||
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
>
|
||||
<option v-for="option in getFilterOperatorOptions(filter.field)" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="admin-members__filter-value">
|
||||
<span class="sr-only">필터 값</span>
|
||||
<select
|
||||
v-if="getFilterFieldType(filter.field) === 'status'"
|
||||
v-model="filter.value"
|
||||
class="admin-members__filter-select h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
>
|
||||
<option v-for="option in statusFilterOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="getFilterFieldType(filter.field) === 'date' && !['empty', 'notEmpty'].includes(filter.operator)"
|
||||
v-model="filter.value"
|
||||
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
type="date"
|
||||
>
|
||||
<input
|
||||
v-else-if="getFilterFieldType(filter.field) !== 'date'"
|
||||
v-model="filter.value"
|
||||
class="admin-members__filter-input h-11 w-full rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#15171a] outline-none focus:border-[#8e9cac] focus:ring-2 focus:ring-[#dce2e8]"
|
||||
type="text"
|
||||
placeholder="값 입력"
|
||||
>
|
||||
<span v-else class="admin-members__filter-empty-value flex h-11 items-center rounded-md border border-[#d7dce0] bg-white px-3 text-sm text-[#8a95a5]">
|
||||
값 필요 없음
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
class="admin-members__filter-delete grid h-11 w-9 place-items-center rounded-md text-[#657080] transition hover:bg-white hover:text-[#15171a]"
|
||||
type="button"
|
||||
aria-label="필터 삭제"
|
||||
@click="removeFilter(filter.id)"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="admin-members__filter-add mt-5 inline-flex items-center gap-2 text-sm font-semibold text-[#17a62b]"
|
||||
type="button"
|
||||
@click="addFilter"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 1v14M1 8h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>필터 추가</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-members__filter-footer mt-5 flex items-center justify-between">
|
||||
<button class="admin-members__filter-reset h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#394047] transition hover:bg-[#eff1f2]" type="button" @click="resetFilters">
|
||||
모두 초기화
|
||||
</button>
|
||||
<button class="admin-members__filter-apply h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black" type="button" @click="applyFilters">
|
||||
필터 적용
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-members__table mt-7 overflow-x-auto">
|
||||
<table class="admin-members__table-inner min-w-[920px] w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
||||
@@ -187,12 +549,7 @@ const formatRelativeTime = (value) => {
|
||||
</div>
|
||||
</td>
|
||||
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||
<span
|
||||
class="admin-members__status inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold"
|
||||
:class="member.activityStatus === '활성'
|
||||
? 'border-green-200 bg-green-50 text-green-700'
|
||||
: 'border-line bg-white text-[#657080]'"
|
||||
>
|
||||
<span class="admin-members__status text-sm text-[#394047]">
|
||||
{{ member.activityStatus }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user