관리자 통계 대시보드 개선 및 v1.9.6 반영

통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:42:23 +09:00
parent 67ea58ccc2
commit ef9efd7af5
25 changed files with 4038 additions and 620 deletions

View 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>