Files
sori.studio/components/admin/AdminAnalyticsTrendChart.vue
zenn ef9efd7af5 관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:42:23 +09:00

394 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>