관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
393
components/admin/AdminAnalyticsTrendChart.vue
Normal file
393
components/admin/AdminAnalyticsTrendChart.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup>
|
||||
import {
|
||||
ANALYTICS_SINGLE_BAR_COLORS,
|
||||
formatAnalyticsChartHeaderLabel,
|
||||
getAnalyticsPeriodLabel,
|
||||
getAnalyticsTrafficStackColor,
|
||||
getAnalyticsTrafficStackSegments,
|
||||
getAnalyticsTrendAxisPrimaryLabel,
|
||||
getAnalyticsTrendAxisSecondaryLabel,
|
||||
getAnalyticsTrendDelta,
|
||||
getAnalyticsTrendDetailLabel,
|
||||
isAnalyticsCurrentTrendRow,
|
||||
paginateAnalyticsTrendRows
|
||||
} from '~/lib/analytics-chart-ui.js'
|
||||
import { getAnalyticsDayKey } from '~/lib/analytics-shared.js'
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
period: {
|
||||
type: String,
|
||||
default: 'weekly'
|
||||
},
|
||||
metricKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
metricLabel: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
formatValue: {
|
||||
type: Function,
|
||||
default: (value) => String(value ?? 0)
|
||||
},
|
||||
formatDelta: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
pageOffset: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
barHeightClass: {
|
||||
type: String,
|
||||
default: 'h-32'
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: '선택한 기간에 표시할 추이 데이터가 없습니다.'
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
trafficStack: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:pageOffset', 'bar-click'])
|
||||
|
||||
const todayKey = getAnalyticsDayKey()
|
||||
const hoveredBarKey = ref(null)
|
||||
|
||||
const pagination = computed(() => {
|
||||
return paginateAnalyticsTrendRows(props.rows, props.period, props.pageOffset)
|
||||
})
|
||||
|
||||
const visibleRows = computed(() => pagination.value.rows)
|
||||
|
||||
const chartHeaderLabel = computed(() => {
|
||||
return formatAnalyticsChartHeaderLabel(props.period, visibleRows.value, todayKey)
|
||||
})
|
||||
|
||||
/**
|
||||
* 추이 행의 고유 키를 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {string} 고유 키
|
||||
*/
|
||||
const getBarRowKey = (row) => {
|
||||
return `${row.startDay || row.day}-${row.endDay || row.day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대가 강조 상태인지 확인한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {boolean} 강조 여부
|
||||
*/
|
||||
const isBarEmphasized = (row) => {
|
||||
if (!hoveredBarKey.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getBarRowKey(row) === hoveredBarKey.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대가 흐리게 표시되는지 확인한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {boolean} 흐림 여부
|
||||
*/
|
||||
const isBarDimmed = (row) => {
|
||||
return Boolean(hoveredBarKey.value && !isBarEmphasized(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 높이 비율을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {number} 높이 %
|
||||
*/
|
||||
const getBarHeight = (row) => {
|
||||
const maxValue = Math.max(...visibleRows.value.map((item) => Number(item[props.metricKey] || 0)), 0)
|
||||
|
||||
if (maxValue <= 0) {
|
||||
return 3
|
||||
}
|
||||
|
||||
const value = Number(row[props.metricKey] || 0)
|
||||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 막대 색상을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {string} 색상
|
||||
*/
|
||||
const getSingleBarColor = (row) => {
|
||||
const emphasized = isBarEmphasized(row)
|
||||
return emphasized ? ANALYTICS_SINGLE_BAR_COLORS.active : ANALYTICS_SINGLE_BAR_COLORS.muted
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 스택 구간 목록을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {Array<{ key: 'search' | 'sns' | 'other', value: number }>}
|
||||
*/
|
||||
const getStackSegments = (row) => {
|
||||
return getAnalyticsTrafficStackSegments(row).segments
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 추이 행에서 현재 행 인덱스를 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {number} 인덱스
|
||||
*/
|
||||
const getRowIndexInAllRows = (row) => {
|
||||
const key = getBarRowKey(row)
|
||||
return props.rows.findIndex((item) => getBarRowKey(item) === key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 증감 표시 문자열을 반환한다.
|
||||
* @param {number | null} delta - 증감값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatDeltaLabel = (delta) => {
|
||||
if (delta === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof props.formatDelta === 'function') {
|
||||
return props.formatDelta(delta)
|
||||
}
|
||||
|
||||
const absolute = Math.abs(delta)
|
||||
return Number.isFinite(absolute) ? String(absolute) : '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 hover 시작을 처리한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBarEnter = (row) => {
|
||||
hoveredBarKey.value = getBarRowKey(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 hover 종료를 처리한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBarLeave = () => {
|
||||
hoveredBarKey.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 페이지로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goPrevPage = () => {
|
||||
if (!pagination.value.canGoPrev) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:pageOffset', props.pageOffset + pagination.value.pageSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 페이지로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goNextPage = () => {
|
||||
if (!pagination.value.canGoNext) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:pageOffset', Math.max(props.pageOffset - pagination.value.pageSize, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 막대 클릭을 처리한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleBarClick = (row) => {
|
||||
if (!props.clickable) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('bar-click', row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 추이 행의 증감값을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {number | null} 증감값
|
||||
*/
|
||||
const getRowDelta = (row) => {
|
||||
return getAnalyticsTrendDelta(props.rows, getRowIndexInAllRows(row), props.metricKey)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="admin-analytics-trend-chart"
|
||||
@mouseleave="handleBarLeave"
|
||||
>
|
||||
<header
|
||||
v-if="showHeader && chartHeaderLabel"
|
||||
class="admin-analytics-trend-chart__header mb-3 flex items-center justify-between gap-3"
|
||||
>
|
||||
<p class="admin-analytics-trend-chart__range text-sm font-semibold text-ink">
|
||||
{{ chartHeaderLabel }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="admin-analytics-trend-chart__body relative" :class="showNavigation ? 'pb-8' : 'pb-0'">
|
||||
<div
|
||||
v-if="visibleRows.length"
|
||||
class="admin-analytics-trend-chart__columns flex gap-1"
|
||||
role="img"
|
||||
:aria-label="`${getAnalyticsPeriodLabel(period)} ${metricLabel} 추이`"
|
||||
>
|
||||
<div
|
||||
v-for="(row, index) in visibleRows"
|
||||
:key="`${period}-${getBarRowKey(row)}`"
|
||||
class="admin-analytics-trend-chart__column flex min-w-0 flex-1 flex-col transition-opacity duration-150"
|
||||
:class="isBarDimmed(row) ? 'opacity-40' : 'opacity-100'"
|
||||
>
|
||||
<component
|
||||
:is="clickable ? 'button' : 'div'"
|
||||
class="admin-analytics-trend-chart__bar-wrap group relative flex w-full items-end border-b border-line"
|
||||
:class="[barHeightClass, clickable ? 'cursor-pointer border-0 bg-transparent p-0' : '']"
|
||||
:type="clickable ? 'button' : undefined"
|
||||
@mouseenter="handleBarEnter(row)"
|
||||
@focus="handleBarEnter(row)"
|
||||
@click="handleBarClick(row)"
|
||||
>
|
||||
<div
|
||||
class="admin-analytics-trend-chart__tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden min-w-max max-w-none -translate-x-1/2 whitespace-nowrap rounded border border-line bg-white px-2.5 py-1.5 text-xs shadow-lg group-hover:block group-focus-visible:block"
|
||||
>
|
||||
<div class="admin-analytics-trend-chart__tooltip-main flex items-center gap-2">
|
||||
<span class="text-sm font-semibold leading-none text-ink">
|
||||
{{ formatValue(row[metricKey]) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getRowDelta(row) !== null"
|
||||
class="inline-flex items-center gap-0.5 text-[11px] font-semibold leading-none"
|
||||
:class="getRowDelta(row) >= 0 ? 'text-[#e5484d]' : 'text-[#2f6feb]'"
|
||||
>
|
||||
<span aria-hidden="true">{{ getRowDelta(row) >= 0 ? '▲' : '▼' }}</span>
|
||||
<span>{{ formatDeltaLabel(getRowDelta(row)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="trafficStack && getStackSegments(row).length > 1"
|
||||
class="admin-analytics-trend-chart__tooltip-breakdown mt-1 text-[10px] leading-relaxed text-muted"
|
||||
>
|
||||
<span class="text-[#e5484d]">검색 {{ row.trafficSearch || 0 }}</span>
|
||||
·
|
||||
<span class="text-[#12a594]">SNS {{ row.trafficSns || 0 }}</span>
|
||||
·
|
||||
<span class="text-[#8b8d98]">기타 {{ row.trafficOther || 0 }}</span>
|
||||
</p>
|
||||
<p class="admin-analytics-trend-chart__tooltip-period mt-1 text-[10px] leading-none text-muted">
|
||||
{{ getAnalyticsTrendDetailLabel(period, row, todayKey) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trafficStack && metricKey === 'visitors'"
|
||||
class="admin-analytics-trend-chart__stack flex w-full flex-col justify-end overflow-hidden rounded-t-sm"
|
||||
:class="compact ? 'mx-auto max-w-[10px]' : ''"
|
||||
:style="{ height: `${getBarHeight(row)}%` }"
|
||||
>
|
||||
<div
|
||||
v-for="segment in getStackSegments(row)"
|
||||
:key="`${getBarRowKey(row)}-${segment.key}`"
|
||||
class="admin-analytics-trend-chart__stack-segment w-full transition-colors duration-150"
|
||||
:style="{
|
||||
flex: `${segment.value} 0 0`,
|
||||
backgroundColor: getAnalyticsTrafficStackColor(segment.key, isBarEmphasized(row))
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="admin-analytics-trend-chart__bar w-full rounded-t-sm transition-colors duration-150"
|
||||
:class="compact ? 'mx-auto max-w-[10px]' : ''"
|
||||
:style="{
|
||||
height: `${getBarHeight(row)}%`,
|
||||
backgroundColor: getSingleBarColor(row)
|
||||
}"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<div class="admin-analytics-trend-chart__axis border-t border-line pt-2 text-center">
|
||||
<span
|
||||
class="admin-analytics-trend-chart__axis-primary inline-block text-[11px] font-medium transition-colors duration-150"
|
||||
:class="isBarEmphasized(row) || (isAnalyticsCurrentTrendRow(period, row, todayKey) && !hoveredBarKey) ? 'rounded-full bg-[#15171a] px-2 py-0.5 text-[10px] text-white' : 'text-muted'"
|
||||
>
|
||||
{{ getAnalyticsTrendAxisPrimaryLabel(period, row, index === visibleRows.length - 1, todayKey) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index)"
|
||||
class="admin-analytics-trend-chart__axis-secondary mt-0.5 block text-[10px] text-muted"
|
||||
>
|
||||
{{ getAnalyticsTrendAxisSecondaryLabel(period, row, visibleRows, index) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!visibleRows.length"
|
||||
class="admin-analytics-trend-chart__empty mt-4 text-center text-xs text-muted"
|
||||
>
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="showNavigation && visibleRows.length"
|
||||
class="admin-analytics-trend-chart__nav pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between"
|
||||
>
|
||||
<button
|
||||
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="이전 기간"
|
||||
:disabled="!pagination.canGoPrev"
|
||||
@click="goPrevPage"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="admin-analytics-trend-chart__nav-button pointer-events-auto grid h-7 w-7 place-items-center rounded border border-line bg-white text-xs text-muted transition hover:bg-paper hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="다음 기간"
|
||||
:disabled="!pagination.canGoNext"
|
||||
@click="goNextPage"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user