관리자 통계 대시보드 개선 및 v1.9.6 반영
통계 추이 차트 공통화, 유입 스택 막대, 집계 시작일 고정, traffic API 오류 수정, deploy 문서 정리를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
# ANALYTICS_HASH_SECRET= ← 선택. 일별 방문자 해시용 비밀. 비우면 MEMBER_SESSION_SECRET을 대신 사용.
|
||||
# ANALYTICS_COLLECTED_SINCE=2026-06-01 ← 선택. 관리자 통계 집계 시작일(YYYY-MM-DD).
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
|
||||
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>
|
||||
37
db/migrations/059_post_analytics_traffic_daily.sql
Normal file
37
db/migrations/059_post_analytics_traffic_daily.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE IF NOT EXISTS post_analytics_traffic_daily (
|
||||
day DATE NOT NULL,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
source_group TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
os_name TEXT NOT NULL,
|
||||
keyword TEXT NOT NULL DEFAULT '',
|
||||
page_views INTEGER NOT NULL DEFAULT 0,
|
||||
visitors INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (day, post_id, source_group, source_name, device_type, os_name, keyword)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_analytics_traffic_daily_post_day_idx
|
||||
ON post_analytics_traffic_daily (post_id, day DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_analytics_traffic_daily_source_idx
|
||||
ON post_analytics_traffic_daily (source_group, source_name);
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
DROP CONSTRAINT IF EXISTS analytics_daily_visitors_scope_check;
|
||||
|
||||
ALTER TABLE analytics_daily_visitors
|
||||
ADD CONSTRAINT analytics_daily_visitors_scope_check CHECK (scope IN ('site', 'post', 'page', 'traffic', 'post_traffic'));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS analytics_daily_visitors_post_traffic_uidx
|
||||
ON analytics_daily_visitors (
|
||||
day,
|
||||
post_id,
|
||||
visitor_hash,
|
||||
source_group,
|
||||
source_name,
|
||||
device_type,
|
||||
os_name,
|
||||
keyword
|
||||
)
|
||||
WHERE scope = 'post_traffic';
|
||||
@@ -1,5 +1,23 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.9.0
|
||||
|
||||
- 관리자 대시보드 통계 추이를 일일·주간·월간·년간 기준으로 볼 수 있게 정리했다.
|
||||
- 주간은 일요일~토요일, 월간은 월 단위, 년간은 연도 단위로 묶어 차트와 상세 모달 기준을 맞췄다.
|
||||
|
||||
## v1.8.9
|
||||
|
||||
- 관리자 글 목록에서 각 게시물의 누적 조회수를 바로 볼 수 있게 했다.
|
||||
- 누적 조회수와 통계 버튼을 별도 조회수 열로 분리하고, 조회수는 숫자만 표시하며 통계는 목록 안 모달로 열리게 했다.
|
||||
- 게시물별 통계를 대시보드 통계 상세 모달과 같은 스타일로 정리하고, 일간·주간·월간 조회/방문자 추이를 확인할 수 있게 했다.
|
||||
- 게시물별 유입 통계를 앞으로 따로 쌓을 수 있도록 집계 테이블을 추가했다.
|
||||
|
||||
## v1.8.8
|
||||
|
||||
- 관리자 대시보드 통계 추이 막대를 클릭하면 해당 날짜나 구간의 상세 통계를 모달로 볼 수 있게 했다.
|
||||
- 상세 모달에서 유입, 디바이스, 유입 키워드, 인기 게시물, 인기 페이지를 함께 확인할 수 있다.
|
||||
- 상세 모달의 모서리를 둥글게 다듬고, 유입 키워드 빈 상태 문구를 간결하게 정리했다.
|
||||
|
||||
## v1.8.7
|
||||
|
||||
- 태그마다 헤더 이미지를 설정할 수 있게 했다.
|
||||
|
||||
462
docs/deploy.md
462
docs/deploy.md
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.8.7에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.9.4에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -16,298 +16,6 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.8.7 참고
|
||||
|
||||
- DB 마이그레이션 `058_tag_header_cover.sql`이 추가되었다.
|
||||
- 배포 전 운영 DB에 `npm run db:migrate:prod`를 실행한다.
|
||||
- 관리자 태그 생성·수정 화면에서 태그 헤더 이미지를 업로드하고 저장한 뒤 `/tag/:slug`에서 기본 제목/설명 대신 이미지가 표시되는지 확인한다.
|
||||
- 오버레이 제목·본문을 비워두면 태그 헤더 이미지 단독으로 표시되는지 확인한다.
|
||||
|
||||
### v1.8.6 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- `/tag/xenoblade3-hero`처럼 일반 태그 상세 URL에 접속했을 때 헤더에 등록된 태그명과 설명이 표시되는지 확인한다.
|
||||
- 홈·게시물 목록·게시물 상세 태그 배지에서 일반 태그도 등록된 이름과 색상을 사용하는지 확인한다.
|
||||
|
||||
### v1.8.5 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 요약이 비어 있는 게시물 본문 첫머리에 인라인 색상 문법이나 HTML 태그가 있어도 공개 목록 설명에는 일반 텍스트만 표시되는지 확인한다.
|
||||
|
||||
### v1.8.4 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기에서 새 태그 `Xenoblade 3`를 입력해 Enter를 누르면 배지에 입력한 대소문자와 공백이 유지되는지 확인한다.
|
||||
- 새 태그 `Xenoblade3/Hero`를 저장하면 태그명은 그대로 유지되고 슬러그는 `xenoblade3-hero`로 저장되는지 확인한다.
|
||||
|
||||
### v1.8.3 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 코드 블록 설정 패널에서 줄번호 표시를 켜면 선언 줄에 `line-numbers`가 저장되고 공개 본문에서 줄번호가 표시되는지 확인한다.
|
||||
- 기존 `linenos`, `nolinenos`, `no-line-numbers` 선언 줄도 공개 본문에서 기존 의미대로 해석되는지 확인한다.
|
||||
|
||||
### v1.8.2 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기에서 `/code` 또는 ``` Enter로 새 코드 블록을 만들 때 줄번호가 기본으로 꺼져 있는지 확인한다.
|
||||
|
||||
### v1.8.1 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기에서 `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` 블록을 삽입하는지 확인한다.
|
||||
- 표 HTML은 `:::html` 블록에 넣고, iframe 코드는 `:::iframe` 블록에 넣는다.
|
||||
- 기존에 HTML 기능으로 작성한 단독 URL은 `:::link` 블록으로, iframe HTML은 `:::iframe` 블록으로 수동 정리한다.
|
||||
- `:::html` 블록에 붙여넣은 표의 `rowspan`, `colspan`, 셀 폭·정렬이 공개 본문에서 유지되는지 확인한다.
|
||||
|
||||
### v1.8.0 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 수정 라이브 모드에서 긴 본문을 위·아래 방향키로 계속 이동할 때 편집 스크롤이 커서를 따라가고 현재 커서가 화면 안에 유지되는지 확인한다.
|
||||
|
||||
### v1.7.9 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 대표 이미지 미디어 라이브러리에 원본 이미지만 보이고 `thumbs/*-card.webp` 파생 파일이 숨겨지는지 확인한다.
|
||||
- 본문 `/이미지`·`/갤러리` 미디어 라이브러리에서도 카드 썸네일이 숨겨지는지 확인한다.
|
||||
- 카드 썸네일 URL을 대표 이미지로 직접 저장하려는 요청이 거부되는지 확인한다.
|
||||
|
||||
### v1.7.8 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기에서 `/이미지`·`/갤러리` 모달이 업로드 탭으로 열리고, 업로드 완료 파일이 라이브러리에서 자동 선택되는지 확인한다.
|
||||
- 공개 게시글 수정 아이콘이 비로그인·관리자 세션만 있는 상태에서는 숨겨지고, 로그인 회원 본인의 글에서만 표시되는지 확인한다.
|
||||
- 주소·HTML 임베드 블록이 위 문단과 과도하게 벌어지지 않고 하단 여백만 유지되는지 확인한다.
|
||||
|
||||
### v1.7.7 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- `/api/homepage-widget`의 `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`가 숫자로 반환되는지 확인한다.
|
||||
- Homepage 개인용 설정은 `refreshInterval: 300000`(5분)을 권장한다.
|
||||
|
||||
### v1.7.6 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 배포 후 `/api/homepage-widget`에서 당일·누적 방문, 페이지뷰, 공개 콘텐츠 수가 반환되는지 확인한다.
|
||||
- Homepage 설정 예시는 `docs/homepage-widget.md`를 사용한다.
|
||||
- Homepage가 Docker에서 실행되면 `localhost:43080` 대신 공개 도메인, NAS 내부 IP 또는 같은 Docker 네트워크의 `http://sori-studio:3000`을 사용한다.
|
||||
|
||||
### v1.6.2 참고
|
||||
|
||||
- DB 마이그레이션 `057_navigation_recommended_favicon_cache.sql` 적용이 필요하다.
|
||||
- 관리자 메뉴 > 추천 사이트 탭에서 적용 아이콘 미리보기 열과 파비콘 새로고침 버튼이 보이는지 확인한다.
|
||||
- 추천 사이트 저장 후 캐시가 없는 외부 URL 항목이 `/uploads/system/recommended-favicon-*.png`로 생성되는지 확인한다.
|
||||
- 공개 홈 Recommended 카드가 서버 캐시 파비콘을 우선 표시하는지 확인한다.
|
||||
- 운영 수동 일괄 갱신: `npm run navigation:refresh-recommended-favicons`
|
||||
|
||||
### v1.6.0 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 미디어 카드 썸네일 탭에서 사용 중인 썸네일 카드 오른쪽 위에 사용 개수 숫자 배지가 표시되는지 확인한다.
|
||||
- 관리자 미디어 프로필 이미지 탭에서 회원 프로필에 연결된 이미지 오른쪽 위에 사용 개수 숫자 배지가 표시되는지 확인한다.
|
||||
- 미사용 필터 상태에서 비슷한 이미지 중 사용 개수 배지가 없는 항목만 남는지 확인한다.
|
||||
|
||||
### v1.5.109 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 공개 메인 화면에서 추천 사이트 Recommended 목록이 왼쪽 사이드바의 Categories 아래에 표시되는지 확인한다.
|
||||
- 공개 일반 화면의 오른쪽 사이드바에서 Recommended 영역이 사라지고 오른쪽 사이드 광고 슬롯이 바로 이어지는지 확인한다.
|
||||
|
||||
### v1.5.108 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 미디어 미디어 라이브러리·카드 썸네일·프로필 이미지 탭에서 전체/미사용 배지형 필터가 모두 표시되는지 확인한다.
|
||||
- 카드 썸네일·프로필 이미지 탭에서 미사용 필터를 누른 뒤 전체 선택·선택 삭제가 가능한지 확인한다.
|
||||
- 게시물 글쓰기 라이브 모드에서 이미지와 갤러리 이미지 hover 시 삭제 버튼만 보이고, 이미지 설정은 해당 블록 포커스 시 오른쪽 패널에서 열리는지 확인한다.
|
||||
|
||||
### v1.5.107 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기에서 줄 중간 텍스트를 선택해 굵게·색상 등 인라인 변경 후 `Cmd/Ctrl+Z`를 눌렀을 때 커서가 줄 앞으로 이동하지 않고 변경 위치 근처에 복원되는지 확인한다.
|
||||
- 관리자 미디어 카드 썸네일 탭에서 미사용 필터를 켠 뒤 전체 선택·선택 삭제가 가능한지 확인한다.
|
||||
- 카드 썸네일 탭의 썸네일 카드에 `카드` 배지가 반복 표시되지 않는지 확인한다.
|
||||
- 일본어 파일명 이미지를 업로드했을 때 `image.jpg` fallback이 아니라 원본 일본어 파일명 기반 URL로 저장되는지 확인한다.
|
||||
|
||||
### v1.5.106 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기 소스 모드에서 텍스트 중간 범위를 선택하고 색상 적용 후 화면 스크롤이 유지되는지 확인한다.
|
||||
- `/html` 또는 `/iframe` 슬래시 명령으로 HTML 블록을 찾을 수 있는지 확인한다.
|
||||
- `<iframe src="https://..."></iframe>` 코드가 공개 본문과 라이브 모드에서 iframe으로 렌더링되는지 확인한다.
|
||||
|
||||
### v1.5.105 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 글쓰기에서 일반 텍스트를 선택하면 오른쪽 텍스트 색상 패널이 열리는지 확인한다.
|
||||
- 색상 선택 후 공개 본문과 라이브 모드에서 `::red[텍스트]`, `::red[**굵은 텍스트**]` 등 인라인 색상과 기존 인라인 마크다운 조합이 표시되는지 확인한다.
|
||||
|
||||
### v1.5.104 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차에서 2줄 이상 제목의 활성 표시선이 항목 높이에 맞춰 표시되는지 확인한다.
|
||||
|
||||
### v1.5.103 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 활성 항목의 왼쪽 브랜드 컬러 막대가 실제 화면에서 표시되는지 확인한다.
|
||||
|
||||
### v1.5.102 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 항목이 24px 높이에서 세로 중앙 정렬되는지 확인한다.
|
||||
- 활성 목차 항목의 왼쪽 표시선이 브랜드 컬러로 정상 표시되는지 확인한다.
|
||||
|
||||
### v1.5.101 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 활성 항목이 바뀌어도 항목 텍스트 시작점이 좌우로 흔들리지 않는지 확인한다.
|
||||
|
||||
### v1.5.100 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차에서 각 항목 왼쪽 라인이 이어져 보이고, 활성 항목 라인이 브랜드 컬러와 더 두꺼운 보더로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.99 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 오른쪽 사이드바 목차 라벨이 `목차`로 표시되는지 확인한다.
|
||||
- 활성 목차 항목의 텍스트와 왼쪽 표시선에 브랜드 컬러가 적용되는지 확인한다.
|
||||
|
||||
### v1.5.98 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 오른쪽 사이드바에서 긴 TOC가 별도 내부 스크롤 없이 전체 목록으로 펼쳐지는지 확인한다.
|
||||
- 게시물 사이드 광고가 긴 TOC 아래에 이어서 표시되는지 확인한다.
|
||||
- 본문 스크롤 중 활성 TOC 항목이 오른쪽 사이드바 전체 스크롤 기준으로 따라오는지 확인한다.
|
||||
|
||||
### v1.5.97 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 데스크톱 오른쪽 사이드바에서 블로그 소개·Follow가 숨겨지고 TOC가 최상단에 표시되는지 확인한다.
|
||||
- 게시물 사이드 광고 코드가 있으면 왼쪽 사이드바가 아니라 오른쪽 TOC 아래에 표시되는지 확인한다.
|
||||
- 긴 목차에서 세로 기준선과 활성 항목 표시선이 정상 표시되는지 확인한다.
|
||||
|
||||
### v1.5.93 참고
|
||||
|
||||
- DB 마이그레이션 `056_site_settings_post_tag_limit.sql` 적용이 필요하다.
|
||||
- 관리자 사이트 설정의 POST 설정에서 태그 최대 개수를 1~10개 사이로 저장할 수 있는지 확인한다.
|
||||
- 게시물 작성에서 설정된 개수 이상 태그를 추가할 수 없고, 저장 API도 초과 태그를 거부하는지 확인한다.
|
||||
- 게시물 작성 본문에서 `/표` 또는 `/table`로 기본 표가 삽입되고 공개/미리보기 본문에서 표로 렌더링되는지 확인한다.
|
||||
|
||||
### v1.5.92 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 새 태그 저장 후 `/admin/tags` 목록으로 이동하고 저장 완료 토스트가 표시되는지 확인한다.
|
||||
- 태그 수정 화면에서 변경 전에는 `변경 저장` 버튼이 비활성화되고, 변경·저장 후 다시 비활성화되는지 확인한다.
|
||||
- 게시물 작성에서 `/이미지` 미디어 모달 업로드 후 본문에 자동 삽입되지 않고 라이브러리 목록에서 직접 선택해 삽입할 수 있는지 확인한다.
|
||||
- 미디어 모달 업로드 중 닫기·추가 업로드가 막히는지 확인한다.
|
||||
|
||||
### v1.5.91 참고
|
||||
|
||||
- DB 마이그레이션 `055_add_post_tag_sort_order.sql` 적용이 필요하다.
|
||||
- 게시물 작성 본문에서 `/이미지`로 미디어 선택 모달을 열었을 때 `thumbs/*-card.webp` 카드 썸네일이 보이지 않는지 확인한다.
|
||||
- 미디어 모달 업로드 탭에 파일을 드롭한 뒤 업로드 중 표시가 나오고 추가 드롭·파일 선택이 막히는지 확인한다.
|
||||
- 태그를 여러 개 가진 공개 게시물 목록에서 첫 번째 태그가 고정 표시되고, 관리자 게시물 목록에서는 모든 태그가 표시되는지 확인한다.
|
||||
|
||||
### v1.5.90 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 라이브 모드에서 코드 블록 아래 일반 문단으로 커서를 이동하면 오른쪽 코드 블록 설정 패널이 닫히는지 확인한다.
|
||||
- 문서 마지막 코드 블록 본문 끝에서 아래 방향키를 눌렀을 때 코드 블록 밖 일반 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
|
||||
### v1.5.89 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 소스 모드에서 코드 블록 본문에 `/volume1/...` 같은 경로를 입력해도 슬래시 명령 메뉴가 열리지 않는지 확인한다.
|
||||
- 소스·라이브 모드에서 줄 삭제나 블록 변환 후 `Cmd/Ctrl+Z`로 되돌리고 `Cmd/Ctrl+Shift+Z`로 다시 실행되는지 확인한다.
|
||||
|
||||
### v1.5.88 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 라이브 모드에서 문서 마지막 인용문 끝에 커서를 두고 아래 방향키를 누르면 인용 밖 일반 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
|
||||
### v1.5.87 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성 라이브 모드에서 코드 블록에 커서를 두면 코드 설정 패널이 열리고, 일반 문단으로 커서를 옮기면 패널이 닫히는지 확인한다.
|
||||
|
||||
### v1.5.86 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 다크 모드 게시물 본문에서 기본 인용문 텍스트가 밝은 사이트 텍스트 색상으로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.85 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 HTML `<title>`이 `게시물 제목`만 출력하는지 확인한다. 구글 검색 결과 반영은 재크롤링 이후 시간이 걸릴 수 있다.
|
||||
|
||||
### v1.5.84 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 모바일 회원가입 2단계에서 이메일 인증번호 입력창이 다른 입력창과 같은 높이로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.83 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 공개 오른쪽 사이드바에서 사이트 로고가 48px 정사각형으로 유지되는지 확인한다.
|
||||
|
||||
### v1.5.82 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 배포 후 `/sitemap.xml`이 `application/xml`로 응답하고 공개 발행글·고정 페이지·태그 URL을 포함하는지 확인한다.
|
||||
- `noindex` 글, 멤버십·비공개·초안·예약 대기 글이 `/sitemap.xml`에 포함되지 않는지 확인한다.
|
||||
- `/robots.txt`가 `Sitemap: https://.../sitemap.xml` 절대 URL을 포함하는지 확인한다.
|
||||
- Google Search Console에는 `https://도메인/sitemap.xml`을 제출한다. 이후 새 게시물 발행 시 sitemap 파일을 수동 갱신할 필요는 없다.
|
||||
|
||||
### v1.5.81 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성·수정 오른쪽 사이드에 `대표 이미지 표시` 토글이 항상 보이는지 확인한다.
|
||||
- 대표 이미지가 없어도 해당 토글을 켜고 저장할 수 있으며, 이후 대표 이미지를 추가하면 상세 제목 아래 표시 설정이 유지되는지 확인한다.
|
||||
|
||||
### v1.5.80 참고
|
||||
|
||||
- DB 마이그레이션 `054_add_post_show_featured_image.sql` 적용 필요.
|
||||
- 새 게시물 본문 이미지를 업로드해도 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp`가 바로 생성되지 않는지 확인한다.
|
||||
- 게시물 대표 이미지를 설정하고 저장하면 해당 원본의 카드 썸네일이 생성되는지 확인한다.
|
||||
- 대표 이미지 원본에 카드 썸네일이 없으면 관리자 미디어 상세에서 `카드 썸네일 생성` 버튼으로 다시 만들 수 있는지 확인한다.
|
||||
- 게시물 상세 제목 아래 대표 이미지는 글쓰기 화면의 `본문 상단 대표 이미지` 옵션이 켜진 글에서만 표시되는지 확인한다.
|
||||
- 운영 기존 업로드 중 대표 이미지 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다. 이 명령은 게시물 대표 이미지 URL만 대상으로 한다.
|
||||
|
||||
### v1.5.79 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 미디어 화면에서 일반 미디어 라이브러리에 `thumbs/*-card.webp` 파일이 섞이지 않고, 카드 썸네일 탭에만 표시되는지 확인한다.
|
||||
- 대표 이미지로 사용 중인 원본에 카드 썸네일이 있으면 카드 썸네일 탭에서 사용 중으로 표시되고 삭제가 차단되는지 확인한다.
|
||||
- 대표 이미지로 사용 중인 원본에 카드 썸네일이 없으면 원본 항목에 `원본` 배지와 fallback 상태가 표시되는지 확인한다.
|
||||
- 운영 기존 업로드 중 fallback 상태가 보이면 `npm run images:backfill-post-thumbnails`를 실행한다.
|
||||
|
||||
### v1.5.78 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 새 게시물 이미지 업로드 시 `/public/uploads/posts/YYYY/MM/thumbs/*-card.webp` 카드 썸네일이 함께 생성되는지 확인한다.
|
||||
- 기존 업로드 이미지가 많은 운영 환경에서는 배포 후 `npm run images:backfill-post-thumbnails`를 한 번 실행해 누락된 카드 썸네일을 생성한다.
|
||||
- 메인 Featured·Latest, 게시물 목록, 태그 목록에서 생성된 썸네일 URL을 우선 요청하고, 썸네일이 없는 외부/기존 이미지는 원본으로 대체되는지 확인한다.
|
||||
|
||||
### v1.5.77 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 메인 화면 Latest compact/list 목록에서 긴 요약이 최대 2줄로 표시되고 발행일·댓글 메타가 잘리지 않는지 확인한다.
|
||||
- 모바일 메인 화면 Latest compact 목록에서 썸네일이 80px 정사각형으로 표시되고, `sm` 이상에서는 기존 비율형 썸네일이 유지되는지 확인한다.
|
||||
- 대표 이미지 없는 placeholder 썸네일의 긴 제목이 모바일에서 썸네일 밖으로 넘치지 않는지 확인한다.
|
||||
|
||||
### v1.5.75 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 작성·수정 화면 오른쪽 설정 패널 하단에 본문 통계가 표시되는지 확인한다.
|
||||
- 본문 입력 시 단어 수, 공백 제외 문자 수, 공백 수, 읽기 시간, 블록 수, 이미지 수가 갱신되는지 확인한다.
|
||||
|
||||
### v1.5.74 참고
|
||||
|
||||
- DB 마이그레이션 `053_site_settings_post_sidebar_ad.sql` 적용 필요.
|
||||
- 사이트 설정 Ads에서 게시물 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 오른쪽 사이드바 TOC 아래에 표시되는지 확인한다.
|
||||
- 게시물 상세 오른쪽 사이드바에서는 일반 오른쪽 사이드 광고가 아니라 게시물 사이드 광고가 표시되는지 확인한다.
|
||||
- 긴 게시물에서 인아티클 광고가 본문 길이에 따라 0~2회로 제한되는지 확인한다.
|
||||
|
||||
### 필수 조건
|
||||
|
||||
- Node.js 22 LTS 권장
|
||||
@@ -337,28 +45,6 @@ openssl rand -hex 32
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### v1.5.55 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 소스 모드 textarea에서 `Cmd+Shift+K`로 현재 줄이 삭제되는지 확인한다.
|
||||
- 소스 모드 textarea에서 여러 줄 선택 후 `Cmd+Shift+K`로 선택 범위 줄들이 삭제되는지 확인한다.
|
||||
- 라이브 모드에서 preview 루트 또는 카드형 블록에 포커스된 상태에서도 `Cmd+Shift+K`로 현재 줄 또는 블록이 삭제되는지 확인한다.
|
||||
|
||||
### v1.5.54 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- `/콜아웃` 삽입 시 기본 선언부가 `emoji=none`으로 들어가고 아이콘이 표시되지 않는지 확인한다.
|
||||
- 오른쪽 블록 설정 패널에서 콜아웃 제목을 입력하면 선언부 `title` 옵션과 라이브·공개 렌더링에 반영되는지 확인한다.
|
||||
- 콜아웃 아이콘 또는 제목이 있을 때 헤더가 왼쪽 상단에 표시되고 본문은 아래 줄에서 시작하는지 확인한다.
|
||||
|
||||
### v1.5.53 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 라이브 콜아웃 본문 5줄 이상에서 `Shift+방향키` 범위 선택이 여러 줄에 걸쳐 유지되는지 확인한다.
|
||||
- 라이브 콜아웃 선택 범위를 삭제하거나 붙여넣을 때 콜아웃 본문 줄만 갱신되는지 확인한다.
|
||||
- 콜아웃 아이콘 사용 시 라이브·사용자 화면 모두 왼쪽 상단에 아이콘이 붙는지 확인한다.
|
||||
- 콜아웃 아이콘 미사용 시 라이브 편집 화면과 사용자 화면 모두 아이콘 자리 표시자가 남지 않는지 확인한다.
|
||||
|
||||
### 로컬 개발 DB
|
||||
|
||||
로컬 개발 DB는 Docker Compose의 `sori-studio-db` 서비스만 실행한다.
|
||||
@@ -382,111 +68,6 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
||||
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||
```
|
||||
|
||||
### v1.5.52 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 연속 콜아웃을 만들고 위 콜아웃에서 한글 입력 후 Enter 시 아래 콜아웃 선언 줄이 유지되는지 확인한다.
|
||||
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
|
||||
- 갤러리·토글·임베드 등 다른 `:::` fenced 블록 편집 시 다음 블록 첫 줄이 교체 범위에 포함되지 않는지 확인한다.
|
||||
|
||||
### v1.5.51 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 라이브 인용 안에서 한글 입력 후 Enter 시 인용 줄이 1줄만 추가되는지 확인한다.
|
||||
- 라이브 콜아웃 안에서 한글 입력 후 Enter 시 콜아웃 본문 줄이 1줄만 추가되는지 확인한다.
|
||||
- 라이브 콜아웃 마지막 줄에서 아래 방향키 입력 시 새 본문 줄이 생성되지 않는지 확인한다.
|
||||
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
|
||||
### v1.5.50 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 라이브 모드에서 한글 `> 텍스트` 입력 후 Enter 시 다음 인용 줄이 생성되고 커서가 내부에 있는지 확인한다.
|
||||
- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
|
||||
- 라이브 콜아웃 본문 여러 줄에서 2번째·3번째 줄 `Cmd+Shift+K`가 해당 줄만 삭제하는지 확인한다.
|
||||
- 라이브 콜아웃에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
|
||||
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 한글 조합 글자가 남지 않는지 확인한다.
|
||||
|
||||
### v1.5.49 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 글쓰기 라이브 모드에서 코드·콜아웃·토글 내부 커서 위치별 `Cmd+Shift+K` 줄 삭제를 확인한다.
|
||||
- 코드·콜아웃·토글 본문 마지막 1줄에서 `Cmd+Shift+K` 입력 시 블록 전체가 삭제되는지 확인한다.
|
||||
- 라이브 콜아웃 마지막 줄에서 아래 방향키로 다음 블록으로 빠져나가는지 확인한다.
|
||||
- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 조합 글자가 남지 않는지 확인한다.
|
||||
|
||||
### v1.5.48 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 게시물 작성 상단 왼쪽 상태 표시가 텍스트만 표시하고, 외부 이동 아이콘이나 링크 동작이 없는지 확인한다.
|
||||
- 공개 게시물 이동은 오른쪽 설정 패널의 `View Post` 링크에서 확인한다.
|
||||
|
||||
### v1.5.47 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 대표 이미지가 있는 공개 게시물이 RSS item에 `media:thumbnail`과 `media:content`를 포함하는지 확인한다.
|
||||
- 상대 이미지 URL이 RSS에서 절대 URL로 변환되는지 확인한다.
|
||||
|
||||
### v1.5.46 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 글쓰기 라이브 모드에서 위에서 아래 방향키로 콜아웃·인용 블록에 진입되는지 확인한다.
|
||||
- 콜아웃·인용 배경 프리셋에 분홍이 보이지 않고 같은 6색 팔레트를 쓰는지 확인한다.
|
||||
- 작은 화면에서 게시물 설정 패널을 열어도 본문 폭이 압축되지 않는지 확인한다.
|
||||
|
||||
### v1.5.45 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 글쓰기에서 라이브 모드 콜아웃·인용 블록 포커스 시 오른쪽 블록 설정 패널이 열리는지 확인한다.
|
||||
- 인용 블록 기본 배경이 회색이고 분홍 옵션이 노출되지 않는지 확인한다.
|
||||
|
||||
### v1.5.44 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 사이트 설정의 블로그 제목·설명, 사이트 정보, 사이트 코드 읽기 화면만 레이아웃 변경되었다.
|
||||
|
||||
### v1.5.43 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 배포 후 `/rss.xml`, `/feed.xml`, `/rss`가 `application/rss+xml`로 응답하고 최근 공개 발행글을 포함하는지 확인한다.
|
||||
- 관리자 SNS 정보에서 RSS 프리셋을 사용할 경우 주소는 `/rss.xml`을 권장한다.
|
||||
|
||||
### v1.5.42 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 공개 오른쪽 사이드바 FOLLOW 영역의 직접 SVG 아이콘 정렬만 수정한다.
|
||||
|
||||
### v1.5.41 마이그레이션
|
||||
|
||||
- `049_fix_social_links_jsonb_string.sql`: 기존에 JSONB 문자열로 잘못 저장된 `site_settings.social_links` 값을 JSONB 배열로 복구한다.
|
||||
- 적용 후 관리자 사이트 설정의 SNS 정보 저장값이 읽기 모드에서 유지되는지 확인한다.
|
||||
|
||||
### v1.5.40 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 관리자 사이트 설정의 SNS 정보에서 프리셋이 없는 서비스는 `직접 SVG`를 선택해 SVG 아이콘과 주소를 함께 저장한다.
|
||||
- `https://`를 생략한 SNS 주소는 저장 시 자동 보정된다.
|
||||
|
||||
### v1.5.39 마이그레이션
|
||||
|
||||
- `048_site_settings_social_links.sql`: `site_settings`에 `social_links` JSONB 컬럼을 추가한다.
|
||||
- 적용 후 관리자 사이트 설정의 SNS 정보와 공개 오른쪽 사이드바 FOLLOW 노출이 정상 동작하는지 확인한다.
|
||||
|
||||
### v1.5.38 마이그레이션
|
||||
|
||||
- `047_site_settings_announcement_alignment.sql`: `site_settings`에 `announcement_alignment` 컬럼을 추가한다.
|
||||
- 적용 후 관리자 사이트 설정의 어나운스 바 정렬(중앙/왼쪽)이 공개 화면에 반영되는지 확인한다.
|
||||
|
||||
### v1.5.35 마이그레이션
|
||||
|
||||
- `045_analytics_traffic_sources.sql`: 방문자 유입원·디바이스·검색 키워드 일별 축약 집계 테이블과 중복 방문 제거용 컬럼을 추가한다.
|
||||
- 적용 이후부터 수집되는 페이지뷰에 대해서만 유입 정보가 쌓인다. 과거 방문 데이터는 소급 집계하지 않는다.
|
||||
- 검색 키워드는 검색엔진이 referrer query를 전달한 경우에만 표시된다.
|
||||
|
||||
### v1.5.34 마이그레이션
|
||||
|
||||
- `044_site_settings_custom_code.sql`: `site_settings`에 `ads_txt`, `custom_head_code`, `custom_footer_code` 컬럼을 추가한다.
|
||||
- 배포 후 `/ads.txt`와 공개 페이지 HTML head/body 하단 코드 삽입이 정상 동작하는지 확인한다.
|
||||
|
||||
### 확인 주소
|
||||
|
||||
- 개발 서버: http://127.0.0.1:43080
|
||||
@@ -508,13 +89,8 @@ docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*
|
||||
터미널에서 바로 확인할 때는 컨테이너 내부 `psql`을 사용한다.
|
||||
|
||||
```bash
|
||||
# DB 준비 상태 확인
|
||||
docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
|
||||
|
||||
# 게시물 개수 확인
|
||||
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
|
||||
|
||||
# psql 콘솔 접속
|
||||
docker exec -it sori-studio-db psql -U sori_studio -d sori_studio
|
||||
```
|
||||
|
||||
@@ -623,6 +199,13 @@ docker compose --env-file .env.production up -d --build
|
||||
- Docker 내부 네트워크 기본값: `10.250.50.0/24`
|
||||
- HTTPS: 3001 (SSL 설정 시)
|
||||
|
||||
### 배포 후 확인
|
||||
|
||||
- `docker ps`에서 `sori-studio`, `sori-studio-db`가 `Up` 상태인지 확인한다.
|
||||
- 공개 사이트와 `/admin` 로그인이 정상 동작하는지 확인한다.
|
||||
- 스키마 변경 배포였다면 `sh scripts/migrate-production-db.sh status`로 미적용 마이그레이션이 없는지 확인한다.
|
||||
- `/sitemap.xml`, `/robots.txt`, `/rss.xml`이 정상 응답하는지 확인한다. Google Search Console에는 `https://도메인/sitemap.xml`을 제출한다.
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스
|
||||
@@ -640,6 +223,13 @@ docker compose --env-file .env.production up -d --build
|
||||
- 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패
|
||||
- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다.
|
||||
|
||||
### 마이그레이션
|
||||
|
||||
- 스키마 변경은 `db/migrations/`의 SQL 파일로 관리한다. 버전별 변경 내역은 `docs/update.md`를 참고한다.
|
||||
- 로컬: `npm run db:migrate:dev`
|
||||
- NAS 운영: `sh scripts/migrate-production-db.sh status` → `migrate`
|
||||
- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `migrate`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `sh scripts/migrate-production-db.sh baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `sh scripts/migrate-production-db.sh baseline 031`.
|
||||
|
||||
### 이메일 인증(Resend, 선택)
|
||||
|
||||
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev`로 `018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
|
||||
@@ -664,13 +254,7 @@ docker compose --env-file .env.production up -d --build
|
||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||
- NAS 운영 DB 마이그레이션은 NAS 호스트에 npm이 없어도 실행할 수 있도록 `sh scripts/migrate-production-db.sh status`로 적용 상태를 확인하고, `sh scripts/migrate-production-db.sh migrate`로 미적용 파일만 실행한다.
|
||||
- 운영 환경 파일은 프로젝트 루트의 `.env.production`을 우선 사용한다. 없으면 `.env`를 읽고, 둘 다 없으면 실행 중인 `sori-studio-db` 컨테이너의 `POSTGRES_DB`·`POSTGRES_USER`를 사용한다.
|
||||
- `schema_migrations`가 없는 기존 운영 DB에서 `posts` 테이블이 감지되면 `migrate`는 001부터 자동 실행하지 않는다. 현재 코드 기준 최신 DB라면 최초 1회 `sh scripts/migrate-production-db.sh baseline`으로 기존 파일을 적용 완료로 기록한다. 특정 번호까지만 기록하려면 예: `sh scripts/migrate-production-db.sh baseline 031`.
|
||||
- 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치).
|
||||
- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다.
|
||||
- 사이트 로고와 파비콘 저장(`logo_url`, `favicon_url`)은 `022_add_site_logo_urls.sql` 적용 후 정상 동작한다.
|
||||
|
||||
### 통계 데이터 보관 정책
|
||||
|
||||
@@ -679,6 +263,7 @@ docker compose --env-file .env.production up -d --build
|
||||
- `analytics_daily_visitors`: 일별 중복 방문 제거용 해시만 담으므로 32일 초과 행은 통계 수집·관리자 조회 흐름에서 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`: 현재 접속자 목록용 임시 데이터이며 90초 초과 행은 조회·수집 시 삭제한다.
|
||||
- 관리자 대시보드 차트는 최대 365일 범위를 조회하며, 차트 범위를 넘는 집계도 누적 통계 원본으로 보관한다.
|
||||
- 관리자 통계 집계 시작일은 `ANALYTICS_COLLECTED_SINCE` 환경 변수로 설정한다. 기본값은 `2026-06-01`이다.
|
||||
|
||||
### 개발/운영 DB 분리 검증 절차
|
||||
|
||||
@@ -806,6 +391,21 @@ docker compose --env-file .env.production restart sori-studio-db
|
||||
|
||||
여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다.
|
||||
|
||||
---
|
||||
|
||||
## 운영 보조 명령
|
||||
|
||||
배포와 직접 관련 없지만 운영 DB·업로드 볼륨에 대해 **필요할 때만** 실행하는 명령이다.
|
||||
|
||||
| 명령 | 용도 |
|
||||
|------|------|
|
||||
| `npm run images:backfill-post-thumbnails` | 게시물 대표 이미지용 카드 썸네일(`thumbs/*-card.webp`)이 누락된 기존 업로드를 일괄 생성 |
|
||||
| `npm run navigation:refresh-recommended-favicons` | 추천 사이트 파비콘 캐시를 수동으로 전체 갱신 |
|
||||
|
||||
Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에서 Homepage가 같은 네트워크에 있으면 `http://sori-studio:3000/api/homepage-widget`을 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-29 v1.9.0 — 대시보드 추이는 서버 버킷 집계로 전환한다
|
||||
|
||||
대시보드 차트에서 30일 이상을 프론트엔드에서 임의 일수로 다시 묶으면 주간의 일요일~토요일 기준, 이번 주·이번 달·올해의 부분 집계 기준을 명확히 맞추기 어렵다. 따라서 `GET /admin/api/analytics/summary`가 `period`를 받아 일간·주간·월간·년간 버킷을 서버에서 직접 만들고, 화면은 응답된 `startDay`·`endDay`·`label`을 그대로 표시한다. 년간은 빈 과거 연도를 과하게 보여 주지 않도록 실제 집계 시작 연도부터 올해까지 최대 8년만 표시한다.
|
||||
|
||||
## 2026-06-29 v1.8.9 — 게시물별 유입은 새 집계로 분리한다
|
||||
|
||||
기존 `post_analytics_daily`에는 게시물별 조회·방문자·읽음·체류·스크롤이 이미 있으므로 글 목록 누적 조회수와 게시물별 기본 통계는 즉시 계산할 수 있다. 하지만 `analytics_traffic_daily`는 사이트 전체 유입 집계라 특정 게시물로 나눌 수 없다. 게시물별 유입을 원문 referrer 저장 없이 유지하려면 별도 축약 집계가 필요하므로 `post_analytics_traffic_daily`를 추가하고, 마이그레이션 적용 이후 새 페이지뷰부터 게시물 ID 기준 유입·디바이스·키워드를 함께 누적한다.
|
||||
|
||||
## 2026-06-29 v1.8.8 — 통계 추이 상세는 기존 일별 집계를 재사용한다
|
||||
|
||||
대시보드 차트 막대는 이미 일자 또는 묶음 구간을 알고 있으므로 별도 원본 로그를 저장하지 않고 `site_analytics_daily`, `analytics_traffic_daily`, `post_analytics_daily`, `page_analytics_daily`를 같은 날짜 범위로 다시 집계한다. 원문 referrer나 검색어를 새로 보관하지 않는 기존 개인정보 최소화 원칙은 유지하며, 검색 키워드는 검색엔진이 referrer query로 전달한 값만 표시한다.
|
||||
|
||||
## 2026-06-26 v1.8.7 — 태그 헤더는 이미지 우선 옵션으로 둔다
|
||||
|
||||
태그 페이지는 기본적으로 태그 이름과 설명을 보여 주지만, 특정 태그는 시리즈나 작품 단위로 별도 분위기를 줄 필요가 있다. 태그 헤더 이미지가 설정된 경우에는 기본 제목·설명을 자동으로 숨기고 이미지를 우선 표시한다. 오버레이 제목·본문은 선택 입력으로 두어, 이미지만 쓰는 태그와 이미지 위 텍스트를 쓰는 태그를 같은 구조에서 처리한다.
|
||||
|
||||
16
docs/map.md
16
docs/map.md
@@ -67,6 +67,7 @@
|
||||
| db/migrations/056_site_settings_post_tag_limit.sql | 사이트 설정의 게시물 태그 최대 개수 컬럼 추가 |
|
||||
| db/migrations/057_navigation_recommended_favicon_cache.sql | 추천 사이트 파비콘 캐시 URL·갱신 시각 컬럼 추가 |
|
||||
| db/migrations/058_tag_header_cover.sql | 태그 헤더 이미지·오버레이 제목·본문 컬럼 추가 |
|
||||
| db/migrations/059_post_analytics_traffic_daily.sql | 게시물별 유입원·디바이스·키워드 일별 축약 집계 테이블 추가 |
|
||||
|
||||
## 서버 미들웨어
|
||||
|
||||
@@ -123,6 +124,8 @@
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
| components/admin/AdminAnalyticsTrendChart.vue | 관리자 통계 추이 막대 차트(축 라벨·증감 툴팁·기간 페이지 이동) |
|
||||
| lib/analytics-chart-ui.js | 통계 차트 라벨·페이지네이션·증감 계산 유틸 |
|
||||
|
||||
## 관리자 컴포저블
|
||||
|
||||
@@ -164,13 +167,14 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 검색·필터(상태·태그·추천·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 대표 이미지 썸네일, 제목 옆 댓글 수, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 조회수·통계 모달, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/카드 썸네일/프로필 이미지** 탭, 글·멤버 목록과 같은 검색창, 파일 직접 추가, 모든 탭 공통 전체/미사용 배지형 필터, 목록 카드 오른쪽 위 사용 개수 배지, 현재 필터 결과의 삭제 가능 항목 전체 선택·선택 삭제, 이미지·비디오·오디오·파일 종류 필터, 원본 fallback·카드 썸네일 사용 배지(카드 썸네일 탭의 카드 배지는 숨김), 대표 이미지 원본 카드 썸네일 생성·재생성, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
@@ -311,7 +315,7 @@
|
||||
| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter·자산 재매핑·게시물 생성) |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
| server/repositories/analytics-repository.js | 방문·게시물·유입 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 |
|
||||
| server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 |
|
||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·기록 |
|
||||
| server/utils/zip-writer.js | 게시물 Export ZIP 생성 유틸리티 |
|
||||
| server/utils/zip-reader.js | 게시물 Import ZIP 읽기 유틸리티 |
|
||||
@@ -320,9 +324,12 @@
|
||||
| server/utils/analytics-heartbeat-input.js | heartbeat 검증·기록 |
|
||||
| server/routes/admin/api/analytics/summary.get.js | 관리자 통계 요약 API |
|
||||
| server/routes/admin/api/analytics/posts.get.js | 관리자 인기 게시물 API |
|
||||
| server/routes/admin/api/analytics/posts/[id].get.js | 관리자 게시물별 통계 API |
|
||||
| server/routes/admin/api/analytics/pages/[id].get.js | 관리자 페이지별 통계 API |
|
||||
| server/routes/admin/api/analytics/pages.get.js | 관리자 인기 페이지 API |
|
||||
| server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API |
|
||||
| server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API |
|
||||
| server/routes/admin/api/analytics/range-detail.get.js | 관리자 통계 추이 날짜·구간 상세 API |
|
||||
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
|
||||
|
||||
## 데이터베이스
|
||||
@@ -366,6 +373,7 @@
|
||||
| db/migrations/045_analytics_traffic_sources.sql | 방문자 유입원·디바이스·키워드 일별 집계 테이블 추가 |
|
||||
| db/migrations/054_add_post_show_featured_image.sql | 게시물 상세 제목 아래 대표 이미지 표시 여부 컬럼 추가 |
|
||||
| db/migrations/055_add_post_tag_sort_order.sql | 게시물별 태그 표시 순서(`post_tags.sort_order`) 추가 |
|
||||
| db/migrations/059_post_analytics_traffic_daily.sql | 게시물별 유입원·디바이스·키워드 일별 축약 집계 테이블 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
13
docs/spec.md
13
docs/spec.md
@@ -489,6 +489,7 @@ components/content/
|
||||
|--------|------|------|
|
||||
| site_analytics_daily | day, page_views, visitors, engaged_views, total_engaged_seconds | 사이트 일별 페이지뷰·방문자·체류 집계 |
|
||||
| post_analytics_daily | day, post_id, views, reads, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 게시물 일별 조회·읽음·스크롤 구간 |
|
||||
| post_analytics_traffic_daily | day, post_id, source_group, source_name, device_type, os_name, keyword, page_views, visitors | 게시물별 유입원·디바이스·키워드 일별 축약 집계 |
|
||||
| page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 |
|
||||
| analytics_traffic_daily | day, source_group, source_name, device_type, os_name, keyword, page_views, visitors | 유입원·디바이스·키워드 일별 축약 집계 |
|
||||
| analytics_daily_visitors | day, scope(`site`/`post`/`page`/`traffic`), post_id?, page_id?, visitor_hash, source_group?, source_name?, device_type?, os_name?, keyword? | 일별 방문자 해시 등록(중복 방문 제거용) |
|
||||
@@ -501,10 +502,12 @@ components/content/
|
||||
- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송.
|
||||
- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다.
|
||||
- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·게시물 제목·접속 유지시간)을 조회한다.
|
||||
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 7일은 일자별로 표시하고, 30일 이상은 선택 기간에 따라 7일·14일·30일 단위로 묶어 카드 폭을 넘지 않게 한다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
|
||||
- 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일일·주간·월간·년간이며, 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 해당 날짜 또는 묶음 구간의 유입·디바이스·유입 키워드·인기 게시물·인기 페이지 상세 모달을 표시한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다.
|
||||
- 관리자 대시보드 **방문자 유입 정보**는 선택 기간 기준 검색·SNS·기타(직접 포함) 유입원, 디바이스/OS, 유입 키워드를 표시한다.
|
||||
- 관리자 대시보드 **인기 게시물**은 선택 기간 조회수와 함께 최근 30일 월간 조회수, 작성일을 표시한다.
|
||||
- 관리자 차트는 최대 365일 범위를 조회한다.
|
||||
- 관리자 글 목록은 게시물별 `post_analytics_daily.views` 누적 합계를 별도 조회수 열에 `1,000`처럼 숫자만 표시하고, 같은 셀 아래 작은 회색 통계 버튼으로 게시물별 통계 모달을 연다. 통계 버튼은 hover 시에만 파란색으로 표시한다.
|
||||
- 관리자 게시물별 통계 모달과 상세 화면은 누적 조회수, 조회수/방문자 추이, 읽음·평균 체류·스크롤 지표, 게시물별 유입원·디바이스·유입 키워드를 표시한다. 추이는 일간(최근 14일), 주간(최근 12주, 일요일~토요일), 월간(최근 12개월) 단위로 전환한다. 게시물별 유입은 `059_post_analytics_traffic_daily.sql` 적용 이후 새로 수집되는 페이지뷰부터 집계된다.
|
||||
- 관리자 대시보드 추이 차트는 일일 7개, 주간 최대 8개, 월간 12개, 년간 최대 8개 버킷을 조회한다. 게시물별 통계와 기타 인기/유입 목록 API는 기존 최대 365일 조회 제한을 유지한다.
|
||||
- `site_analytics_daily`, `post_analytics_daily`, `analytics_traffic_daily`는 사이트 전체 방문자와 게시물별 조회수, 유입 통계 누적 원본이므로 자동 삭제하지 않는다.
|
||||
- `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다.
|
||||
- `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다.
|
||||
@@ -572,11 +575,14 @@ components/content/
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
- `POST /admin/api/auth/logout` - 로그아웃
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다.
|
||||
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다.
|
||||
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
|
||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/realtime?limit=20` - 현재 접속자 요약·목록(로그인 사용자 닉네임·아바타 포함)
|
||||
- `GET /admin/api/analytics/traffic?days=30` - 기간 내 유입원·디바이스·유입 키워드 통계
|
||||
- `GET /admin/api/analytics/range-detail?startDay=YYYY-MM-DD&endDay=YYYY-MM-DD&limit=5` - 통계 추이 막대 상세(요약·유입·디바이스·유입 키워드·인기 게시물·인기 페이지). 최대 31일 범위로 제한한다.
|
||||
- `GET /admin/api/analytics/posts/:id?period=daily&days=14` - 게시물별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 게시물별 유입·디바이스·키워드)
|
||||
- `GET /admin/api/analytics/pages/:id?period=daily&days=14` - 페이지별 통계(누적·기간 요약, 일간/주간/월간 조회·방문자 추이, 체류·스크롤 구간)
|
||||
- `GET /admin/api/posts` - 글 목록
|
||||
- `POST /admin/api/posts` - 글 작성
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
@@ -899,6 +905,7 @@ ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
ANALYTICS_HASH_SECRET=replace-with-random-password
|
||||
ANALYTICS_COLLECTED_SINCE=2026-06-01
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.9.6
|
||||
|
||||
- 관리자 대시보드 방문자 추이: 검색(빨강)·SNS(청록)·기타(회색) 유입 스택 막대 적용.
|
||||
- 관리자 통계 차트: 기본은 옅은 색, hover 시 해당 막대만 색상 강조·나머지는 흐리게 표시.
|
||||
|
||||
## v1.9.5
|
||||
|
||||
- 관리자 대시보드 `/admin/api/analytics/traffic` 오류 수정: 정의되지 않은 `trendPeriod` 참조로 인한 500 응답 제거.
|
||||
- 관리자 대시보드: 클라이언트에서 서버 전용 `lib/analytics.js` import 제거(`analytics-shared.js` 사용).
|
||||
|
||||
## v1.9.4
|
||||
|
||||
- 관리자 대시보드 통계 추이: 집계 시작일을 `2026-06-01` 기준으로 고정(`ANALYTICS_COLLECTED_SINCE` 환경 변수로 변경 가능).
|
||||
- 관리자 대시보드 통계 추이: 집계 시작일 이전 구간은 0으로 표시.
|
||||
- 관리자 대시보드 통계 추이 안내 문구를 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식으로 변경.
|
||||
- `GET /admin/api/analytics/summary` 응답에 `collectedSince` 필드 추가.
|
||||
- `docs/deploy.md`에서 버전별 QA 참고 섹션을 제거하고 배포·빌드·운영 절차만 남기도록 정리.
|
||||
|
||||
## v1.9.3
|
||||
|
||||
- 관리자 대시보드 통계 추이: 차트별 네비게이션을 기간 버튼 옆 공통 ‹ › 로 통합.
|
||||
- 관리자 통계 추이: 서버 버킷 수 확장(일 28·주 24·월 24·년 16)으로 이전 기간 이동 지원.
|
||||
- 관리자 페이지 목록: 조회수·통계 모달 추가(`GET /admin/api/analytics/pages/:id`).
|
||||
|
||||
## v1.9.2
|
||||
|
||||
- 관리자 통계 차트 툴팁: 제목 제거, 값·증감 가로 배치·`whitespace-nowrap`으로 세로 늘어짐 완화.
|
||||
|
||||
## v1.9.1
|
||||
|
||||
- 관리자 통계 차트: 차트 중앙의 중복 기간 라벨(주간·월간 등) 제거, 막대별 일·월·년 축 라벨 및 현재 구간(오늘·이번주·이번달) 표시.
|
||||
- 관리자 통계 차트: 툴팁에 이전 구간 대비 증감(▲/▼) 표시, 기간별 페이지 이동(‹ ›) 지원.
|
||||
- `AdminAnalyticsTrendChart` 공통 컴포넌트로 대시보드·게시물 통계 모달·게시물 통계 페이지 차트 통일.
|
||||
|
||||
## v1.9.0
|
||||
|
||||
- 관리자 대시보드 통계 추이 선택지를 일일·주간·월간·년간으로 변경.
|
||||
- 관리자 대시보드 통계 추이 집계를 서버 기준 일간 7일·주간 최대 8주·월간 12개월·년간 최대 8년 단위로 정리.
|
||||
- 관리자 대시보드 통계 추이 막대 클릭 상세 모달이 선택 집계 구간의 시작일·종료일을 사용하도록 정리.
|
||||
|
||||
## v1.8.9
|
||||
|
||||
- 관리자 글 목록에 게시물별 누적 조회수와 통계 모달 버튼 추가.
|
||||
- 관리자 글 목록 조회수와 통계 버튼을 제목 영역에서 별도 조회수 열로 분리.
|
||||
- 관리자 글 목록 조회수는 숫자만 표시하고 통계 버튼은 기본 회색·hover 파란색으로 정리.
|
||||
- 관리자 글 목록 통계 버튼 클릭 시 게시물 수정 화면 대신 게시물별 통계 모달이 열리도록 수정.
|
||||
- 관리자 게시물별 통계 모달을 대시보드 통계 상세 모달 스타일과 같은 카드형 구성으로 정리.
|
||||
- 관리자 게시물별 통계 추이를 7/30/90일 대신 일간·주간·월간 집계로 전환.
|
||||
- 관리자 게시물별 통계 화면 추가.
|
||||
- 게시물별 통계 API와 앞으로 수집될 게시물별 유입 집계 테이블 추가.
|
||||
- DB 마이그레이션 `059_post_analytics_traffic_daily.sql` 추가.
|
||||
|
||||
## v1.8.8
|
||||
|
||||
- 관리자 대시보드 통계 추이 막대 클릭 시 날짜·구간 상세 모달을 표시하도록 추가.
|
||||
- 통계 상세 모달에 유입·디바이스·유입 키워드·인기 게시물·인기 페이지 정보를 표시하도록 추가.
|
||||
- 통계 상세 모달의 유입 키워드 안내 문구를 간결하게 정리.
|
||||
- 통계 상세 모달 패널에 0.5rem 둥근 모서리 적용.
|
||||
|
||||
## v1.8.7
|
||||
|
||||
- 태그별 헤더 이미지·오버레이 제목·오버레이 본문 필드 추가.
|
||||
|
||||
523
lib/analytics-chart-ui.js
Normal file
523
lib/analytics-chart-ui.js
Normal file
@@ -0,0 +1,523 @@
|
||||
import { getAnalyticsDayKey } from './analytics-shared.js'
|
||||
|
||||
/** @type {Record<string, number>} 집계 단위별 차트 페이지 크기 */
|
||||
export const ANALYTICS_CHART_PAGE_SIZES = {
|
||||
daily: 7,
|
||||
weekly: 8,
|
||||
monthly: 12,
|
||||
yearly: 8
|
||||
}
|
||||
|
||||
/** @type {Record<string, number>} 집계 단위별 차트 전체 버킷 수(페이지 이동용) */
|
||||
export const ANALYTICS_CHART_TOTAL_BUCKETS = {
|
||||
daily: 28,
|
||||
weekly: 24,
|
||||
monthly: 24,
|
||||
yearly: 16
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 단위별 차트 페이지 크기를 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @returns {number} 페이지 크기
|
||||
*/
|
||||
export const getAnalyticsChartPageSize = (period) => {
|
||||
return ANALYTICS_CHART_PAGE_SIZES[period] || 8
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 단위별 차트 전체 버킷 수를 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @returns {number} 전체 버킷 수
|
||||
*/
|
||||
export const getAnalyticsChartTotalBuckets = (period) => {
|
||||
return ANALYTICS_CHART_TOTAL_BUCKETS[period] || 16
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추이 행의 종료일을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {string} 종료일
|
||||
*/
|
||||
const getAnalyticsTrendEndDay = (row) => {
|
||||
return String(row?.endDay || row?.day || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추이 행의 시작일을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {string} 시작일
|
||||
*/
|
||||
const getAnalyticsTrendStartDay = (row) => {
|
||||
return String(row?.startDay || row?.day || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 키를 Date 객체로 변환한다.
|
||||
* @param {string} dayKey - YYYY-MM-DD
|
||||
* @returns {Date | null} Date 객체
|
||||
*/
|
||||
const toAnalyticsDate = (dayKey) => {
|
||||
const value = String(dayKey || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 객체를 통계 날짜 키로 변환한다.
|
||||
* @param {Date} date - Date 객체
|
||||
* @returns {string} YYYY-MM-DD
|
||||
*/
|
||||
const formatAnalyticsDateKey = (date) => {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 날짜 키 중 더 늦은 날짜를 반환한다.
|
||||
* @param {string} leftDay - YYYY-MM-DD
|
||||
* @param {string} rightDay - YYYY-MM-DD
|
||||
* @returns {string} 더 늦은 날짜 키
|
||||
*/
|
||||
const getLaterAnalyticsDayKey = (leftDay, rightDay) => {
|
||||
const left = String(leftDay || '').trim()
|
||||
const right = String(rightDay || '').trim()
|
||||
if (!left) {
|
||||
return right
|
||||
}
|
||||
if (!right) {
|
||||
return left
|
||||
}
|
||||
return left > right ? left : right
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 단위에 맞춘 표시용 시작일을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {string} collectedSince - 최초 집계일(YYYY-MM-DD)
|
||||
* @returns {string} 정렬된 시작일
|
||||
*/
|
||||
export const getAnalyticsPeriodAlignedStartDay = (period, collectedSince) => {
|
||||
const date = toAnalyticsDate(collectedSince)
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (period === 'monthly') {
|
||||
return formatAnalyticsDateKey(new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)))
|
||||
}
|
||||
|
||||
if (period === 'yearly') {
|
||||
return formatAnalyticsDateKey(new Date(Date.UTC(date.getUTCFullYear(), 0, 1)))
|
||||
}
|
||||
|
||||
if (period === 'weekly') {
|
||||
const weekStart = new Date(date)
|
||||
weekStart.setUTCDate(date.getUTCDate() - date.getUTCDay())
|
||||
return formatAnalyticsDateKey(weekStart)
|
||||
}
|
||||
|
||||
return collectedSince
|
||||
}
|
||||
|
||||
/**
|
||||
* 추이 조회 시작일과 집계 시작일 중 유효한 시작일을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {string} requestedStartDay - 요청 시작일
|
||||
* @param {string} collectedSince - 최초 집계일
|
||||
* @returns {string} 유효 시작일
|
||||
*/
|
||||
export const getAnalyticsEffectiveTrendStartDay = (period, requestedStartDay, collectedSince) => {
|
||||
const alignedStartDay = getAnalyticsPeriodAlignedStartDay(period, collectedSince)
|
||||
if (!alignedStartDay) {
|
||||
return requestedStartDay
|
||||
}
|
||||
|
||||
return getLaterAnalyticsDayKey(requestedStartDay, alignedStartDay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 시작일을 한국어 안내 문구로 변환한다.
|
||||
* @param {string} dayKey - YYYY-MM-DD
|
||||
* @returns {string} 안내 문구용 날짜
|
||||
*/
|
||||
export const formatAnalyticsCollectedSinceLabel = (dayKey) => {
|
||||
const date = toAnalyticsDate(dayKey)
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `${date.getUTCFullYear()}년 ${date.getUTCMonth() + 1}월 ${date.getUTCDate()}일`
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추이 안내 문구를 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {string} collectedSince - 최초 집계일
|
||||
* @returns {string} 안내 문구
|
||||
*/
|
||||
export const getAnalyticsTrendDescription = (period, collectedSince) => {
|
||||
if (!collectedSince) {
|
||||
return '아직 집계된 통계 데이터가 없습니다.'
|
||||
}
|
||||
|
||||
const label = formatAnalyticsCollectedSinceLabel(collectedSince)
|
||||
if (!label) {
|
||||
return '아직 집계된 통계 데이터가 없습니다.'
|
||||
}
|
||||
|
||||
return `통계는 ${label}부터 집계한 데이터를 표시합니다.`
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 진행 중인 집계 구간인지 확인한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {Object} row - 추이 행
|
||||
* @param {string} [todayKey] - 오늘 날짜 키
|
||||
* @returns {boolean} 현재 구간 여부
|
||||
*/
|
||||
export const isAnalyticsCurrentTrendRow = (period, row, todayKey = getAnalyticsDayKey()) => {
|
||||
const startDay = getAnalyticsTrendStartDay(row)
|
||||
const endDay = getAnalyticsTrendEndDay(row)
|
||||
|
||||
if (!startDay || !endDay || endDay !== todayKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (period === 'daily') {
|
||||
return startDay === todayKey
|
||||
}
|
||||
|
||||
return period === 'weekly' || period === 'monthly' || period === 'yearly'
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 구간의 상대 라벨을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @returns {string} 상대 라벨
|
||||
*/
|
||||
export const getAnalyticsCurrentPeriodLabel = (period) => {
|
||||
if (period === 'daily') {
|
||||
return '오늘'
|
||||
}
|
||||
|
||||
if (period === 'weekly') {
|
||||
return '이번주'
|
||||
}
|
||||
|
||||
if (period === 'monthly') {
|
||||
return '이번달'
|
||||
}
|
||||
|
||||
if (period === 'yearly') {
|
||||
return '올해'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추이 툴팁·상세용 라벨을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {Object} row - 추이 행
|
||||
* @param {string} [todayKey] - 오늘 날짜 키
|
||||
* @returns {string} 라벨
|
||||
*/
|
||||
export const getAnalyticsTrendDetailLabel = (period, row, todayKey = getAnalyticsDayKey()) => {
|
||||
if (!row) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (row.label) {
|
||||
return row.label
|
||||
}
|
||||
|
||||
if (isAnalyticsCurrentTrendRow(period, row, todayKey)) {
|
||||
return getAnalyticsCurrentPeriodLabel(period)
|
||||
}
|
||||
|
||||
const startDay = getAnalyticsTrendStartDay(row)
|
||||
const endDay = getAnalyticsTrendEndDay(row)
|
||||
const startDate = toAnalyticsDate(startDay)
|
||||
const endDate = toAnalyticsDate(endDay)
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (period === 'yearly') {
|
||||
return `${startDate.getUTCFullYear()}년`
|
||||
}
|
||||
|
||||
if (period === 'monthly') {
|
||||
return `${startDate.getUTCFullYear()}.${String(startDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (period === 'weekly') {
|
||||
const startLabel = `${startDate.getUTCMonth() + 1}.${String(startDate.getUTCDate()).padStart(2, '0')}`
|
||||
const endLabel = `${endDate.getUTCMonth() + 1}.${String(endDate.getUTCDate()).padStart(2, '0')}`
|
||||
return startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`
|
||||
}
|
||||
|
||||
return `${String(startDate.getUTCMonth() + 1).padStart(2, '0')}.${String(startDate.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 축 기본 라벨을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {Object} row - 추이 행
|
||||
* @param {boolean} isLast - 마지막 막대 여부
|
||||
* @param {string} [todayKey] - 오늘 날짜 키
|
||||
* @returns {string} 축 라벨
|
||||
*/
|
||||
export const getAnalyticsTrendAxisPrimaryLabel = (period, row, isLast, todayKey = getAnalyticsDayKey()) => {
|
||||
if (isLast && isAnalyticsCurrentTrendRow(period, row, todayKey)) {
|
||||
return getAnalyticsCurrentPeriodLabel(period)
|
||||
}
|
||||
|
||||
const startDate = toAnalyticsDate(getAnalyticsTrendStartDay(row))
|
||||
if (!startDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (period === 'daily' || period === 'weekly') {
|
||||
return String(startDate.getUTCDate())
|
||||
}
|
||||
|
||||
if (period === 'monthly') {
|
||||
return String(startDate.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (period === 'yearly') {
|
||||
return String(startDate.getUTCFullYear())
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 축 보조 라벨(월·년)을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {Object} row - 추이 행
|
||||
* @param {Array<Object>} rows - 표시 중인 추이 행
|
||||
* @param {number} index - 행 인덱스
|
||||
* @returns {string} 보조 라벨
|
||||
*/
|
||||
export const getAnalyticsTrendAxisSecondaryLabel = (period, row, rows, index) => {
|
||||
const startDate = toAnalyticsDate(getAnalyticsTrendStartDay(row))
|
||||
if (!startDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const previousRow = index > 0 ? rows[index - 1] : null
|
||||
const previousDate = previousRow ? toAnalyticsDate(getAnalyticsTrendStartDay(previousRow)) : null
|
||||
|
||||
if (period === 'daily' || period === 'weekly') {
|
||||
if (!previousDate || startDate.getUTCMonth() !== previousDate.getUTCMonth()) {
|
||||
return `${startDate.getUTCMonth() + 1}월`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
if (period === 'monthly' || period === 'yearly') {
|
||||
if (!previousDate || startDate.getUTCFullYear() !== previousDate.getUTCFullYear()) {
|
||||
return `${startDate.getUTCFullYear()}년`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 구간 대비 증감을 반환한다.
|
||||
* @param {Array<Object>} rows - 전체 추이 행
|
||||
* @param {number} index - 현재 행 인덱스
|
||||
* @param {string} metricKey - 지표 키
|
||||
* @returns {number | null} 증감값
|
||||
*/
|
||||
export const getAnalyticsTrendDelta = (rows, index, metricKey) => {
|
||||
if (!Array.isArray(rows) || index <= 0 || index >= rows.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const current = Number(rows[index]?.[metricKey] || 0)
|
||||
const previous = Number(rows[index - 1]?.[metricKey] || 0)
|
||||
|
||||
return current - previous
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 추이 행을 페이지 단위로 잘라 반환한다.
|
||||
* @param {Array<Object>} rows - 전체 추이 행
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {number} pageOffset - 0이면 최신 구간, 값이 클수록 과거
|
||||
* @returns {{ rows: Array<Object>, canGoPrev: boolean, canGoNext: boolean, pageOffset: number, pageSize: number }}
|
||||
*/
|
||||
export const paginateAnalyticsTrendRows = (rows, period, pageOffset = 0) => {
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
const pageSize = getAnalyticsChartPageSize(period)
|
||||
const safeOffset = Math.max(Number(pageOffset) || 0, 0)
|
||||
const endIndex = Math.max(list.length - safeOffset, 0)
|
||||
const startIndex = Math.max(endIndex - pageSize, 0)
|
||||
|
||||
return {
|
||||
rows: list.slice(startIndex, endIndex),
|
||||
canGoPrev: startIndex > 0,
|
||||
canGoNext: safeOffset > 0,
|
||||
pageOffset: safeOffset,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 헤더에 표시할 기간 라벨을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @param {Array<Object>} visibleRows - 표시 중인 추이 행
|
||||
* @param {string} [todayKey] - 오늘 날짜 키
|
||||
* @returns {string} 기간 라벨
|
||||
*/
|
||||
export const formatAnalyticsChartHeaderLabel = (period, visibleRows, todayKey = getAnalyticsDayKey()) => {
|
||||
if (!visibleRows?.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const firstRow = visibleRows[0]
|
||||
const lastRow = visibleRows[visibleRows.length - 1]
|
||||
|
||||
if (visibleRows.length === 1 && isAnalyticsCurrentTrendRow(period, lastRow, todayKey) && period === 'daily') {
|
||||
const date = toAnalyticsDate(getAnalyticsTrendStartDay(lastRow))
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
return `${date.getUTCFullYear()}.${String(date.getUTCMonth() + 1).padStart(2, '0')}.${String(date.getUTCDate()).padStart(2, '0')} ${weekdays[date.getUTCDay()]}`
|
||||
}
|
||||
|
||||
const startDate = toAnalyticsDate(getAnalyticsTrendStartDay(firstRow))
|
||||
const endDate = toAnalyticsDate(getAnalyticsTrendEndDay(lastRow))
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return `${date.getUTCFullYear()}.${String(date.getUTCMonth() + 1).padStart(2, '0')}.${String(date.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (period === 'monthly' && visibleRows.length === 1) {
|
||||
return `${startDate.getUTCFullYear()}.${String(startDate.getUTCMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (formatDate(startDate) === formatDate(endDate)) {
|
||||
return formatDate(startDate)
|
||||
}
|
||||
|
||||
return `${formatDate(startDate)} ~ ${formatDate(endDate)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 단위 표시명을 반환한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @returns {string} 표시명
|
||||
*/
|
||||
export const getAnalyticsPeriodLabel = (period) => {
|
||||
if (period === 'daily') {
|
||||
return '일간'
|
||||
}
|
||||
|
||||
if (period === 'weekly') {
|
||||
return '주간'
|
||||
}
|
||||
|
||||
if (period === 'monthly') {
|
||||
return '월간'
|
||||
}
|
||||
|
||||
if (period === 'yearly') {
|
||||
return '년간'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/** @type {Record<string, { active: string, muted: string }>} 유입 스택 막대 색상 */
|
||||
export const ANALYTICS_TRAFFIC_STACK_COLORS = {
|
||||
search: { active: '#e5484d', muted: '#f3b6b8' },
|
||||
sns: { active: '#12a594', muted: '#a8e8df' },
|
||||
other: { active: '#8b8d98', muted: '#d5d7de' }
|
||||
}
|
||||
|
||||
/** @type {{ active: string, muted: string }} 단일 막대 색상 */
|
||||
export const ANALYTICS_SINGLE_BAR_COLORS = {
|
||||
active: '#15171a',
|
||||
muted: '#d5d7de'
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 스택 막대 색상을 반환한다.
|
||||
* @param {'search' | 'sns' | 'other'} key - 유입 구분
|
||||
* @param {boolean} emphasized - 강조 여부
|
||||
* @returns {string} 색상
|
||||
*/
|
||||
export const getAnalyticsTrafficStackColor = (key, emphasized) => {
|
||||
const colors = ANALYTICS_TRAFFIC_STACK_COLORS[key] || ANALYTICS_TRAFFIC_STACK_COLORS.other
|
||||
return emphasized ? colors.active : colors.muted
|
||||
}
|
||||
|
||||
/**
|
||||
* 추이 행의 유입 스택 구간을 반환한다.
|
||||
* @param {Object} row - 추이 행
|
||||
* @returns {{ total: number, segments: Array<{ key: 'search' | 'sns' | 'other', value: number }> }}
|
||||
*/
|
||||
export const getAnalyticsTrafficStackSegments = (row) => {
|
||||
const total = Number(row?.visitors || 0)
|
||||
const search = Number(row?.trafficSearch || 0)
|
||||
const sns = Number(row?.trafficSns || 0)
|
||||
const other = Number(row?.trafficOther || 0)
|
||||
const trafficSum = search + sns + other
|
||||
|
||||
if (total <= 0) {
|
||||
return { total: 0, segments: [] }
|
||||
}
|
||||
|
||||
if (trafficSum <= 0) {
|
||||
return {
|
||||
total,
|
||||
segments: [{ key: 'other', value: total }]
|
||||
}
|
||||
}
|
||||
|
||||
const scale = total / trafficSum
|
||||
const scaledSearch = Math.round(search * scale)
|
||||
const scaledSns = Math.round(sns * scale)
|
||||
let scaledOther = total - scaledSearch - scaledSns
|
||||
|
||||
if (scaledOther < 0) {
|
||||
scaledOther = 0
|
||||
}
|
||||
|
||||
/** @type {Array<{ key: 'search' | 'sns' | 'other', value: number }>} */
|
||||
const segments = []
|
||||
|
||||
if (scaledSearch > 0) {
|
||||
segments.push({ key: 'search', value: scaledSearch })
|
||||
}
|
||||
|
||||
if (scaledSns > 0) {
|
||||
segments.push({ key: 'sns', value: scaledSns })
|
||||
}
|
||||
|
||||
if (scaledOther > 0) {
|
||||
segments.push({ key: 'other', value: scaledOther })
|
||||
}
|
||||
|
||||
if (!segments.length) {
|
||||
segments.push({ key: 'other', value: total })
|
||||
}
|
||||
|
||||
return { total, segments }
|
||||
}
|
||||
@@ -93,6 +93,21 @@ export const ANALYTICS_RETENTION_PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||
/** @type {number[]} 스크롤 구간 임계값 */
|
||||
export const ANALYTICS_SCROLL_THRESHOLDS = [0.25, 0.5, 0.75, 1]
|
||||
|
||||
/** @type {string} 통계 집계 기본 시작일(YYYY-MM-DD) */
|
||||
export const ANALYTICS_COLLECTED_SINCE_DEFAULT = '2026-06-01'
|
||||
|
||||
/**
|
||||
* 통계 집계 시작일을 YYYY-MM-DD 형식으로 정규화한다.
|
||||
* @param {string} [value] - 후보 시작일
|
||||
* @returns {string} 정규화된 시작일
|
||||
*/
|
||||
export const normalizeAnalyticsCollectedSinceDay = (value) => {
|
||||
const raw = String(value || ANALYTICS_COLLECTED_SINCE_DEFAULT).trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(raw)
|
||||
? raw
|
||||
: ANALYTICS_COLLECTED_SINCE_DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* 체류시간(초)을 상한 내로 보정한다.
|
||||
* @param {number} seconds - 체류시간
|
||||
|
||||
@@ -3,11 +3,13 @@ import { createHash } from 'node:crypto'
|
||||
export {
|
||||
ANALYTICS_ACTIVE_SESSION_TTL_SECONDS,
|
||||
ANALYTICS_CHART_MAX_DAYS,
|
||||
ANALYTICS_COLLECTED_SINCE_DEFAULT,
|
||||
ANALYTICS_ENGAGED_MIN_SECONDS,
|
||||
ANALYTICS_MAX_DURATION_SECONDS,
|
||||
ANALYTICS_RETENTION_PURGE_INTERVAL_MS,
|
||||
ANALYTICS_SCROLL_THRESHOLDS,
|
||||
ANALYTICS_VISITOR_HASH_RETENTION_DAYS,
|
||||
normalizeAnalyticsCollectedSinceDay,
|
||||
clampAnalyticsDurationSeconds,
|
||||
clampAnalyticsScrollRatio,
|
||||
getAnalyticsDayBefore,
|
||||
|
||||
@@ -62,6 +62,7 @@ export default defineNuxtConfig({
|
||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
|
||||
analyticsHashSecret: process.env.ANALYTICS_HASH_SECRET || '',
|
||||
analyticsCollectedSince: process.env.ANALYTICS_COLLECTED_SINCE || '2026-06-01',
|
||||
resendApiKey: process.env.RESEND_API_KEY || '',
|
||||
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
|
||||
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
|
||||
@@ -81,7 +82,8 @@ export default defineNuxtConfig({
|
||||
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||
maxVideoFileSize: Number(process.env.MAX_VIDEO_FILE_SIZE || 209715200),
|
||||
maxAudioFileSize: Number(process.env.MAX_AUDIO_FILE_SIZE || 52428800),
|
||||
maxDocumentFileSize: Number(process.env.MAX_DOCUMENT_FILE_SIZE || 52428800)
|
||||
maxDocumentFileSize: Number(process.env.MAX_DOCUMENT_FILE_SIZE || 52428800),
|
||||
analyticsCollectedSince: process.env.ANALYTICS_COLLECTED_SINCE || '2026-06-01'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.7",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.7",
|
||||
"version": "1.9.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.8.7",
|
||||
"version": "1.9.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup>
|
||||
import { ANALYTICS_COLLECTED_SINCE_DEFAULT } from '~/lib/analytics-shared.js'
|
||||
import { getAnalyticsTrendDescription, getAnalyticsTrendDetailLabel, paginateAnalyticsTrendRows } from '~/lib/analytics-chart-ui.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
@@ -8,15 +11,20 @@ const { data: posts } = await useFetch('/admin/api/posts', {
|
||||
})
|
||||
|
||||
const analyticsRangeOptions = [
|
||||
{ label: '7일', days: 7 },
|
||||
{ label: '30일', days: 30 },
|
||||
{ label: '3개월', days: 90 },
|
||||
{ label: '6개월', days: 180 },
|
||||
{ label: '12개월', days: 365 }
|
||||
{ label: '일일', period: 'daily', days: 7 },
|
||||
{ label: '주간', period: 'weekly', days: 56 },
|
||||
{ label: '월간', period: 'monthly', days: 365 },
|
||||
{ label: '년간', period: 'yearly', days: 365 * 8 }
|
||||
]
|
||||
const selectedAnalyticsDays = ref(30)
|
||||
const selectedAnalyticsPeriod = ref('weekly')
|
||||
const analyticsChartPageOffset = ref(0)
|
||||
const selectedAnalyticsRange = computed(() => {
|
||||
return analyticsRangeOptions.find((option) => option.period === selectedAnalyticsPeriod.value) || analyticsRangeOptions[1]
|
||||
})
|
||||
const selectedAnalyticsDays = computed(() => selectedAnalyticsRange.value.days)
|
||||
const analyticsQuery = computed(() => ({
|
||||
days: selectedAnalyticsDays.value
|
||||
days: selectedAnalyticsDays.value,
|
||||
period: selectedAnalyticsPeriod.value
|
||||
}))
|
||||
|
||||
const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/admin/api/analytics/summary', {
|
||||
@@ -29,6 +37,7 @@ const { data: analyticsSummary, refresh: refreshSummary } = await useFetch('/adm
|
||||
loggedInNow: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50Reach: 0,
|
||||
collectedSince: ANALYTICS_COLLECTED_SINCE_DEFAULT,
|
||||
trends: []
|
||||
})
|
||||
})
|
||||
@@ -73,18 +82,55 @@ const { data: realtime, refresh: refreshRealtime } = await useFetch('/admin/api/
|
||||
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
|
||||
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
|
||||
const analyticsRangeLabel = computed(() => {
|
||||
return analyticsRangeOptions.find((option) => option.days === selectedAnalyticsDays.value)?.label || '30일'
|
||||
return selectedAnalyticsRange.value.label
|
||||
})
|
||||
const trendRows = computed(() => analyticsSummary.value.trends || [])
|
||||
const trendStartDay = computed(() => trendRows.value[0]?.day || '')
|
||||
const trendEndDay = computed(() => trendRows.value[trendRows.value.length - 1]?.day || '')
|
||||
const analyticsTrendDescription = computed(() => {
|
||||
return getAnalyticsTrendDescription(
|
||||
selectedAnalyticsPeriod.value,
|
||||
analyticsSummary.value?.collectedSince || ANALYTICS_COLLECTED_SINCE_DEFAULT
|
||||
)
|
||||
})
|
||||
const selectedTrendDetail = ref(null)
|
||||
const selectedTrendDetailLabel = ref('')
|
||||
const isTrendDetailOpen = ref(false)
|
||||
const trafficSources = computed(() => trafficSummary.value.sources || [])
|
||||
const trafficDevices = computed(() => trafficSummary.value.devices || [])
|
||||
const trafficKeywords = computed(() => trafficSummary.value.keywords || [])
|
||||
const isTrendDetailLoading = ref(false)
|
||||
const trendDetailError = ref('')
|
||||
const totalTrafficPageViews = computed(() => {
|
||||
return trafficSources.value.reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
|
||||
})
|
||||
|
||||
const chartPagination = computed(() => {
|
||||
return paginateAnalyticsTrendRows(trendRows.value, selectedAnalyticsPeriod.value, analyticsChartPageOffset.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 통계 차트 이전 기간으로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goAnalyticsChartPrevPage = () => {
|
||||
if (!chartPagination.value.canGoPrev) {
|
||||
return
|
||||
}
|
||||
|
||||
analyticsChartPageOffset.value += chartPagination.value.pageSize
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 차트 다음 기간으로 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const goAnalyticsChartNextPage = () => {
|
||||
if (!chartPagination.value.canGoNext) {
|
||||
return
|
||||
}
|
||||
|
||||
analyticsChartPageOffset.value = Math.max(analyticsChartPageOffset.value - chartPagination.value.pageSize, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 날짜를 YYYY.MM.DD로 표시한다.
|
||||
* @param {string | null} value - 날짜 값
|
||||
@@ -205,127 +251,86 @@ const formatTrafficPercent = (value) => {
|
||||
return `${Math.round((Number(value || 0) / total) * 100)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 막대 툴팁 문구를 반환한다.
|
||||
* @param {{ key: 'visitors' | 'avgEngagedSeconds' | 'scroll50Reach', title: string }} metric - 지표 정보
|
||||
* @param {Object} row - 추세 행
|
||||
* @returns {string} 툴팁 문구
|
||||
*/
|
||||
const getTrendTooltipLabel = (metric, row) => {
|
||||
const dayLabel = row.label || formatTrendDayLabel(row.day)
|
||||
return `${dayLabel} · ${metric.title} ${formatTrendValue(metric.key, row[metric.key])}`
|
||||
}
|
||||
|
||||
const chartMetrics = computed(() => [
|
||||
{
|
||||
key: 'visitors',
|
||||
title: '방문자수',
|
||||
label: `${getTrendSummaryValue('visitors')}명`
|
||||
label: `${getTrendSummaryValue('visitors')}명`,
|
||||
metricLabel: '방문자수'
|
||||
},
|
||||
{
|
||||
key: 'avgEngagedSeconds',
|
||||
title: '평균 체류시간',
|
||||
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds'))
|
||||
label: formatEngagedDuration(getTrendSummaryValue('avgEngagedSeconds')),
|
||||
metricLabel: '평균 체류시간'
|
||||
},
|
||||
{
|
||||
key: 'scroll50Reach',
|
||||
title: '50% 스크롤 도달',
|
||||
label: `${getTrendSummaryValue('scroll50Reach')}회`
|
||||
label: `${getTrendSummaryValue('scroll50Reach')}회`,
|
||||
metricLabel: '50% 스크롤 도달'
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 차트 막대 높이 비율을 반환한다.
|
||||
* @param {'visitors' | 'avgEngagedSeconds' | 'scroll50Reach'} key - 추세 키
|
||||
* 체류시간 증감 표시 문자열을 반환한다.
|
||||
* @param {number} delta - 증감 초
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatEngagedDurationDelta = (delta) => {
|
||||
const value = Math.abs(Number(delta) || 0)
|
||||
if (value < 60) {
|
||||
return `${value}초`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60)
|
||||
const remainSeconds = value % 60
|
||||
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 날짜 상세 모달을 연다.
|
||||
* @param {Object} row - 추세 행
|
||||
* @returns {number} 높이 %
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const getTrendBarHeight = (key, row) => {
|
||||
const rows = chartTrendRows.value
|
||||
const maxValue = Math.max(...rows.map((item) => Number(item[key] || 0)), 0)
|
||||
const openTrendDetail = async (row) => {
|
||||
const startDay = row.startDay || row.day
|
||||
const endDay = row.endDay || row.day
|
||||
|
||||
if (maxValue <= 0) {
|
||||
return 3
|
||||
if (!startDay || !endDay) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = Number(row[key] || 0)
|
||||
return Math.max(Math.round((value / maxValue) * 100), value > 0 ? 8 : 3)
|
||||
}
|
||||
isTrendDetailOpen.value = true
|
||||
isTrendDetailLoading.value = true
|
||||
trendDetailError.value = ''
|
||||
selectedTrendDetail.value = null
|
||||
selectedTrendDetailLabel.value = getAnalyticsTrendDetailLabel(selectedAnalyticsPeriod.value, row)
|
||||
|
||||
/**
|
||||
* 추세 시작·종료 날짜 라벨을 반환한다.
|
||||
* @param {string} day - YYYY-MM-DD
|
||||
* @returns {string} 날짜 라벨
|
||||
*/
|
||||
const formatTrendDayLabel = (day) => {
|
||||
if (!day) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [, month, date] = day.split('-')
|
||||
return `${month}.${date}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 기간에 맞는 차트 표시 집계 단위를 반환한다.
|
||||
* @param {number} days - 선택 기간
|
||||
* @returns {number} 묶을 일수
|
||||
*/
|
||||
const getTrendChartBucketSize = (days) => {
|
||||
if (days <= 14) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (days <= 60) {
|
||||
return 7
|
||||
}
|
||||
|
||||
if (days <= 180) {
|
||||
return 14
|
||||
}
|
||||
|
||||
return 30
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 표시용 추세 묶음 행을 만든다.
|
||||
* @param {Array<Object>} rows - 일자별 추세 행
|
||||
* @param {number} bucketSize - 묶을 일수
|
||||
* @returns {Array<Object>} 차트 표시 행
|
||||
*/
|
||||
const buildTrendChartRows = (rows, bucketSize) => {
|
||||
if (bucketSize <= 1) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
label: formatTrendDayLabel(row.day)
|
||||
}))
|
||||
}
|
||||
|
||||
const buckets = []
|
||||
for (let index = 0; index < rows.length; index += bucketSize) {
|
||||
const bucketRows = rows.slice(index, index + bucketSize)
|
||||
const firstRow = bucketRows[0]
|
||||
const lastRow = bucketRows[bucketRows.length - 1]
|
||||
const engagedRows = bucketRows.filter((row) => Number(row.avgEngagedSeconds || 0) > 0)
|
||||
const totalEngagedSeconds = engagedRows.reduce((sum, row) => sum + Number(row.avgEngagedSeconds || 0), 0)
|
||||
const startLabel = formatTrendDayLabel(firstRow?.day)
|
||||
const endLabel = formatTrendDayLabel(lastRow?.day)
|
||||
|
||||
buckets.push({
|
||||
day: firstRow?.day || '',
|
||||
label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`,
|
||||
visitors: bucketRows.reduce((sum, row) => sum + Number(row.visitors || 0), 0),
|
||||
avgEngagedSeconds: engagedRows.length ? Math.round(totalEngagedSeconds / engagedRows.length) : 0,
|
||||
scroll50Reach: bucketRows.reduce((sum, row) => sum + Number(row.scroll50Reach || 0), 0)
|
||||
try {
|
||||
selectedTrendDetail.value = await $fetch('/admin/api/analytics/range-detail', {
|
||||
query: {
|
||||
startDay,
|
||||
endDay,
|
||||
limit: 5
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
trendDetailError.value = '날짜별 통계 상세를 불러오지 못했습니다.'
|
||||
} finally {
|
||||
isTrendDetailLoading.value = false
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
const chartTrendRows = computed(() => {
|
||||
return buildTrendChartRows(trendRows.value, getTrendChartBucketSize(selectedAnalyticsDays.value))
|
||||
})
|
||||
/**
|
||||
* 추세 날짜 상세 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeTrendDetail = () => {
|
||||
isTrendDetailOpen.value = false
|
||||
selectedTrendDetail.value = null
|
||||
trendDetailError.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 접속자가 보고 있는 화면명을 반환한다.
|
||||
@@ -366,7 +371,8 @@ onUnmounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedAnalyticsDays, () => {
|
||||
watch(selectedAnalyticsPeriod, () => {
|
||||
analyticsChartPageOffset.value = 0
|
||||
refreshSummary()
|
||||
refreshTopPosts()
|
||||
refreshTopPages()
|
||||
@@ -408,20 +414,38 @@ watch(selectedAnalyticsDays, () => {
|
||||
통계 추이
|
||||
</h2>
|
||||
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
|
||||
선택한 기간의 방문자수, 평균 체류시간, 50% 스크롤 도달 추이
|
||||
{{ analyticsTrendDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-dashboard__range flex flex-wrap gap-1">
|
||||
<div class="admin-dashboard__range flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
v-for="option in analyticsRangeOptions"
|
||||
:key="option.days"
|
||||
:key="option.period"
|
||||
type="button"
|
||||
class="admin-dashboard__range-button border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
||||
:class="option.days === selectedAnalyticsDays ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
|
||||
@click="selectedAnalyticsDays = option.days"
|
||||
:class="option.period === selectedAnalyticsPeriod ? 'bg-[#15171a] text-white hover:bg-[#15171a] hover:text-white' : 'bg-white'"
|
||||
@click="selectedAnalyticsPeriod = option.period"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-dashboard__range-nav grid h-[26px] w-7 place-items-center 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="!chartPagination.canGoPrev"
|
||||
@click="goAnalyticsChartPrevPage"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
class="admin-dashboard__range-nav grid h-[26px] w-7 place-items-center 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="!chartPagination.canGoNext"
|
||||
@click="goAnalyticsChartNextPage"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -439,40 +463,23 @@ watch(selectedAnalyticsDays, () => {
|
||||
{{ metric.label }}
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
class="admin-dashboard__chart-bars mt-4 flex h-32 items-end gap-1 border-b border-line"
|
||||
role="img"
|
||||
:aria-label="`${metric.title} ${analyticsRangeLabel} 추이`"
|
||||
>
|
||||
<div
|
||||
v-for="row in chartTrendRows"
|
||||
:key="`${metric.key}-${row.day}`"
|
||||
class="admin-dashboard__chart-bar-wrap group relative flex h-full min-w-[3px] flex-1 items-end"
|
||||
tabindex="0"
|
||||
:aria-label="getTrendTooltipLabel(metric, row)"
|
||||
>
|
||||
<div
|
||||
class="admin-dashboard__chart-tooltip pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 whitespace-nowrap rounded bg-[#15171a] px-2 py-1 text-[11px] font-medium text-white shadow-lg group-hover:block group-focus-visible:block"
|
||||
>
|
||||
{{ getTrendTooltipLabel(metric, row) }}
|
||||
</div>
|
||||
<div
|
||||
class="admin-dashboard__chart-bar w-full rounded-t-sm bg-[#15171a] transition-colors group-hover:bg-[#2f6feb] group-focus-visible:bg-[#2f6feb]"
|
||||
:style="{ height: `${getTrendBarHeight(metric.key, row)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="!chartTrendRows.length"
|
||||
class="admin-dashboard__chart-empty mt-4 text-center text-xs text-muted"
|
||||
>
|
||||
선택한 기간에 표시할 추이 데이터가 없습니다.
|
||||
</p>
|
||||
<div class="mt-2 flex justify-between text-[11px] text-muted">
|
||||
<span>{{ formatTrendDayLabel(trendStartDay) }}</span>
|
||||
<span>{{ analyticsRangeLabel }}</span>
|
||||
<span>{{ formatTrendDayLabel(trendEndDay) }}</span>
|
||||
</div>
|
||||
<AdminAnalyticsTrendChart
|
||||
class="mt-4"
|
||||
:rows="trendRows"
|
||||
:period="selectedAnalyticsPeriod"
|
||||
:metric-key="metric.key"
|
||||
:metric-label="metric.metricLabel"
|
||||
:format-value="(value) => formatTrendValue(metric.key, value)"
|
||||
:format-delta="metric.key === 'avgEngagedSeconds' ? formatEngagedDurationDelta : undefined"
|
||||
:traffic-stack="metric.key === 'visitors'"
|
||||
v-model:page-offset="analyticsChartPageOffset"
|
||||
:show-header="false"
|
||||
:show-navigation="false"
|
||||
compact
|
||||
clickable
|
||||
bar-height-class="h-32"
|
||||
@bar-click="openTrendDetail"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -823,5 +830,226 @@ watch(selectedAnalyticsDays, () => {
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isTrendDetailOpen"
|
||||
class="admin-dashboard__trend-modal fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-10"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeTrendDetail"
|
||||
>
|
||||
<section class="admin-dashboard__trend-modal-panel w-full max-w-4xl rounded-lg border border-line bg-white shadow-xl">
|
||||
<div class="admin-dashboard__trend-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
통계 상세
|
||||
</p>
|
||||
<h2 class="mt-1 text-lg font-semibold text-ink">
|
||||
{{ selectedTrendDetailLabel }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-dashboard__trend-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
||||
@click="closeTrendDetail"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-dashboard__trend-modal-body space-y-4 p-5">
|
||||
<p
|
||||
v-if="isTrendDetailLoading"
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
날짜별 통계를 불러오는 중입니다.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="trendDetailError"
|
||||
class="text-sm text-red-600"
|
||||
>
|
||||
{{ trendDetailError }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="selectedTrendDetail">
|
||||
<div class="admin-dashboard__trend-modal-summary grid gap-3 sm:grid-cols-4">
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
페이지뷰
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ selectedTrendDetail.summary.pageViews }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
방문자
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ selectedTrendDetail.summary.visitors }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
평균 체류
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatEngagedDuration(selectedTrendDetail.summary.avgEngagedSeconds) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
50% 스크롤
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ selectedTrendDetail.summary.scroll50Reach }}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
유입
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTrendDetail.sources.length"
|
||||
class="mt-3 space-y-2"
|
||||
>
|
||||
<li
|
||||
v-for="row in selectedTrendDetail.sources"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="font-medium text-ink">{{ getTrafficGroupLabel(row.sourceGroup) }} · {{ row.sourceName }}</span>
|
||||
<span class="text-right text-muted">조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
해당 기간에 집계된 유입 정보가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
디바이스
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTrendDetail.devices.length"
|
||||
class="mt-3 space-y-2"
|
||||
>
|
||||
<li
|
||||
v-for="row in selectedTrendDetail.devices"
|
||||
:key="`${row.deviceType}-${row.osName}`"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="font-medium text-ink">{{ row.deviceType }} · {{ row.osName }}</span>
|
||||
<span class="text-right text-muted">조회 {{ row.pageViews }} · 방문자 {{ row.visitors }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
해당 기간에 집계된 디바이스 정보가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
유입 키워드
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTrendDetail.keywords.length"
|
||||
class="mt-3 space-y-2"
|
||||
>
|
||||
<li
|
||||
v-for="row in selectedTrendDetail.keywords"
|
||||
:key="`${row.sourceName}-${row.keyword}`"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-ink">{{ row.keyword }}</span>
|
||||
<span class="shrink-0 text-right text-muted">{{ row.sourceName }} · {{ row.pageViews }}회</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
해당 기간에 수집된 유입 키워드가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
인기 게시물
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTrendDetail.topPosts.length"
|
||||
class="mt-3 space-y-2"
|
||||
>
|
||||
<li
|
||||
v-for="row in selectedTrendDetail.topPosts"
|
||||
:key="row.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="`/post/${row.slug}`"
|
||||
class="min-w-0 truncate font-medium text-ink hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{{ row.title }}
|
||||
</NuxtLink>
|
||||
<span class="shrink-0 text-right text-muted">조회 {{ row.views }} · 읽음 {{ row.reads }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
해당 기간에 집계된 게시물 조회가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard__trend-modal-section border border-line bg-paper p-4 lg:col-span-2">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
인기 페이지
|
||||
</h3>
|
||||
<ul
|
||||
v-if="selectedTrendDetail.topPages.length"
|
||||
class="mt-3 grid gap-2 md:grid-cols-2"
|
||||
>
|
||||
<li
|
||||
v-for="row in selectedTrendDetail.topPages"
|
||||
:key="row.id"
|
||||
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="`/pages/${row.slug}`"
|
||||
class="min-w-0 truncate font-medium text-ink hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{{ row.title }}
|
||||
</NuxtLink>
|
||||
<span class="shrink-0 text-right text-muted">조회 {{ row.views }} · 방문자 {{ row.visitors }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="mt-3 text-xs text-muted"
|
||||
>
|
||||
해당 기간에 집계된 페이지 조회가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,13 @@ definePageMeta({
|
||||
|
||||
const deletingId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const analyticsModalPage = ref(null)
|
||||
const analyticsModalData = ref(createDefaultPageAnalytics())
|
||||
const analyticsModalPeriod = ref('daily')
|
||||
const analyticsModalMetric = ref('views')
|
||||
const analyticsModalChartPageOffset = ref(0)
|
||||
const analyticsModalLoading = ref(false)
|
||||
const analyticsModalError = ref('')
|
||||
|
||||
const { openMenuId, closeMenu } = useAdminRowMenu()
|
||||
|
||||
@@ -12,6 +19,112 @@ const { data: pages, refresh } = await useFetch('/admin/api/pages', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const analyticsPeriodOptions = [
|
||||
{ label: '일간', value: 'daily', days: 14 },
|
||||
{ label: '주간', value: 'weekly', days: 84 },
|
||||
{ label: '월간', value: 'monthly', days: 365 }
|
||||
]
|
||||
|
||||
const analyticsMetricOptions = [
|
||||
{ label: '조회수', value: 'views' },
|
||||
{ label: '방문자', value: 'visitors' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 페이지별 통계 기본값을 생성한다.
|
||||
* @returns {Object} 페이지별 통계 기본값
|
||||
*/
|
||||
function createDefaultPageAnalytics() {
|
||||
return {
|
||||
updatedAt: '',
|
||||
total: {
|
||||
views: 0,
|
||||
visitors: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50: 0,
|
||||
scroll75: 0,
|
||||
scroll100: 0
|
||||
},
|
||||
period: {
|
||||
views: 0,
|
||||
visitors: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50: 0,
|
||||
scroll75: 0,
|
||||
scroll100: 0
|
||||
},
|
||||
trends: []
|
||||
}
|
||||
}
|
||||
|
||||
/** 페이지 통계 모달이 열려 있는지 */
|
||||
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPage.value))
|
||||
|
||||
/**
|
||||
* 숫자를 관리자 목록 표시 형식으로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAdminNumber = (value) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
|
||||
* @param {string | null} value - 날짜 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAdminDateTimeLabel = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
|
||||
return `${year}.${month}.${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 표시 문자열로 변환한다.
|
||||
* @param {number} seconds - 초
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatEngagedDuration = (seconds) => {
|
||||
const value = Number(seconds) || 0
|
||||
if (value < 60) {
|
||||
return `${value}초`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60)
|
||||
const remainSeconds = value % 60
|
||||
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 통계 기간 표시명을 반환한다.
|
||||
* @returns {string} 기간 표시명
|
||||
*/
|
||||
const getAnalyticsPeriodLabel = () => {
|
||||
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.label || '일간'
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 통계 기간 조회 일수를 반환한다.
|
||||
* @returns {number} 조회 일수
|
||||
*/
|
||||
const getAnalyticsPeriodDays = () => {
|
||||
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.days || 14
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 상태 표시 문자열 생성
|
||||
* @param {Object} page - 페이지
|
||||
@@ -64,6 +177,69 @@ const formatDate = (value) => {
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 통계 모달 데이터를 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchAnalyticsModalData = async () => {
|
||||
if (!analyticsModalPage.value?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
analyticsModalLoading.value = true
|
||||
analyticsModalError.value = ''
|
||||
|
||||
try {
|
||||
analyticsModalData.value = await $fetch(`/admin/api/analytics/pages/${analyticsModalPage.value.id}`, {
|
||||
query: {
|
||||
period: analyticsModalPeriod.value,
|
||||
days: getAnalyticsPeriodDays()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
analyticsModalData.value = createDefaultPageAnalytics()
|
||||
analyticsModalError.value = error?.data?.message || '페이지 통계를 불러오지 못했습니다.'
|
||||
} finally {
|
||||
analyticsModalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 통계 모달을 연다.
|
||||
* @param {Object} page - 페이지
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openAnalyticsModal = async (page) => {
|
||||
closeMenu()
|
||||
analyticsModalPage.value = page
|
||||
analyticsModalPeriod.value = 'daily'
|
||||
analyticsModalMetric.value = 'views'
|
||||
analyticsModalChartPageOffset.value = 0
|
||||
analyticsModalData.value = createDefaultPageAnalytics()
|
||||
await fetchAnalyticsModalData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 통계 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeAnalyticsModal = () => {
|
||||
analyticsModalPage.value = null
|
||||
analyticsModalError.value = ''
|
||||
analyticsModalData.value = createDefaultPageAnalytics()
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 모달 집계 단위를 변경한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const selectAnalyticsModalPeriod = async (period) => {
|
||||
analyticsModalPeriod.value = period
|
||||
analyticsModalChartPageOffset.value = 0
|
||||
await fetchAnalyticsModalData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 고정 페이지 삭제
|
||||
* @param {Object} page - 삭제할 고정 페이지
|
||||
@@ -118,6 +294,7 @@ const deletePage = async (page) => {
|
||||
<tr>
|
||||
<th class="admin-pages__cell px-4 py-3">제목</th>
|
||||
<th class="admin-pages__cell px-4 py-3">상태</th>
|
||||
<th class="admin-pages__cell px-4 py-3">조회수</th>
|
||||
<th class="admin-pages__cell px-4 py-3">수정일</th>
|
||||
<th class="admin-pages__cell admin-pages__cell-actions w-12 px-2 py-3 text-right">
|
||||
<span class="sr-only">관리</span>
|
||||
@@ -139,6 +316,18 @@ const deletePage = async (page) => {
|
||||
{{ getPageStatusLabel(page) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-pages__cell admin-pages__cell-views px-4 py-4">
|
||||
<p class="admin-pages__view-count text-[#394047]">
|
||||
{{ formatAdminNumber(page.totalViews) }}
|
||||
</p>
|
||||
<button
|
||||
class="admin-pages__analytics-link mt-1 inline-flex text-xs text-muted transition-colors hover:text-[#2f6feb]"
|
||||
type="button"
|
||||
@click.stop="openAnalyticsModal(page)"
|
||||
>
|
||||
통계
|
||||
</button>
|
||||
</td>
|
||||
<td class="admin-pages__cell px-4 py-4">
|
||||
{{ formatDate(page.updatedAt) }}
|
||||
</td>
|
||||
@@ -175,5 +364,164 @@ const deletePage = async (page) => {
|
||||
<p v-if="pages.length === 0" class="admin-pages__empty mt-6 text-sm text-muted">
|
||||
아직 작성된 페이지가 없습니다.
|
||||
</p>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isAnalyticsModalOpen"
|
||||
class="admin-pages__analytics-modal fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-pages-analytics-modal-title"
|
||||
@click.self="closeAnalyticsModal"
|
||||
>
|
||||
<article class="admin-pages__analytics-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
|
||||
<header class="admin-pages__analytics-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
페이지 통계
|
||||
</p>
|
||||
<h2 id="admin-pages-analytics-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
|
||||
{{ analyticsModalPage?.title || '(제목 없음)' }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ formatAdminDateTimeLabel(analyticsModalData.updatedAt) }} 기준
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="admin-pages__analytics-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
||||
type="button"
|
||||
@click="closeAnalyticsModal"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-pages__analytics-modal-body space-y-4 p-5">
|
||||
<p v-if="analyticsModalLoading" class="text-sm text-muted">
|
||||
페이지 통계를 불러오는 중입니다.
|
||||
</p>
|
||||
<p v-else-if="analyticsModalError" class="text-sm text-red-600">
|
||||
{{ analyticsModalError }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-pages__analytics-modal-summary grid gap-3 sm:grid-cols-4">
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
누적 조회수
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.total.views) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
방문자
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.visitors) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
평균 체류
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatEngagedDuration(analyticsModalData.period.avgEngagedSeconds) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
50% 스크롤
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.scroll50) }}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="admin-pages__analytics-modal-chart border border-line bg-paper p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
통계 추이
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ getAnalyticsPeriodLabel() }} 기준
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="inline-flex border border-line bg-white">
|
||||
<button
|
||||
v-for="metric in analyticsMetricOptions"
|
||||
:key="metric.value"
|
||||
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
|
||||
:class="analyticsModalMetric === metric.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
|
||||
type="button"
|
||||
@click="analyticsModalMetric = metric.value"
|
||||
>
|
||||
{{ metric.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="inline-flex border border-line bg-white">
|
||||
<button
|
||||
v-for="option in analyticsPeriodOptions"
|
||||
:key="option.value"
|
||||
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
|
||||
:class="analyticsModalPeriod === option.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
|
||||
type="button"
|
||||
@click="selectAnalyticsModalPeriod(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminAnalyticsTrendChart
|
||||
class="mt-4"
|
||||
:rows="analyticsModalData.trends"
|
||||
:period="analyticsModalPeriod"
|
||||
:metric-key="analyticsModalMetric"
|
||||
:metric-label="analyticsModalMetric === 'views' ? '조회수' : '방문자'"
|
||||
:format-value="formatAdminNumber"
|
||||
v-model:page-offset="analyticsModalChartPageOffset"
|
||||
:show-header="false"
|
||||
compact
|
||||
bar-height-class="h-36"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="admin-pages__analytics-modal-engagement grid gap-3 sm:grid-cols-3">
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
75% 스크롤
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.scroll75) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
100% 스크롤
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.scroll100) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
선택 기간 조회수
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.views) }}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
426
pages/admin/posts/[id]/analytics.vue
Normal file
426
pages/admin/posts/[id]/analytics.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const id = computed(() => String(route.params.id || ''))
|
||||
const selectedPeriod = ref('daily')
|
||||
const selectedMetric = ref('views')
|
||||
const analyticsChartPageOffset = ref(0)
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '일간', value: 'daily', days: 14 },
|
||||
{ label: '주간', value: 'weekly', days: 84 },
|
||||
{ label: '월간', value: 'monthly', days: 365 }
|
||||
]
|
||||
|
||||
const metricOptions = [
|
||||
{ label: '조회수', value: 'views' },
|
||||
{ label: '방문자', value: 'visitors' }
|
||||
]
|
||||
|
||||
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const analyticsQuery = computed(() => ({
|
||||
period: selectedPeriod.value,
|
||||
days: periodOptions.find((option) => option.value === selectedPeriod.value)?.days || 14
|
||||
}))
|
||||
|
||||
const { data: analytics, refresh } = await useFetch(() => `/admin/api/analytics/posts/${id.value}`, {
|
||||
query: analyticsQuery,
|
||||
default: () => ({
|
||||
updatedAt: '',
|
||||
total: {
|
||||
views: 0,
|
||||
visitors: 0,
|
||||
reads: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50: 0,
|
||||
scroll75: 0,
|
||||
scroll100: 0
|
||||
},
|
||||
period: {
|
||||
views: 0,
|
||||
visitors: 0,
|
||||
reads: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50: 0,
|
||||
scroll75: 0,
|
||||
scroll100: 0
|
||||
},
|
||||
trends: [],
|
||||
sources: [],
|
||||
devices: [],
|
||||
keywords: []
|
||||
})
|
||||
})
|
||||
|
||||
watch(selectedPeriod, () => {
|
||||
analyticsChartPageOffset.value = 0
|
||||
refresh()
|
||||
})
|
||||
|
||||
/**
|
||||
* 숫자를 관리자 통계 표시 형식으로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatStatNumber = (value) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 값을 YYYY.MM.DD 형식으로 표시한다.
|
||||
* @param {string | null} value - 날짜 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAdminDate = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
|
||||
* @param {string | null} value - 날짜 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAdminDateTime = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
|
||||
return `${formatAdminDate(value)} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 읽기 쉬운 문자열로 변환한다.
|
||||
* @param {number} seconds - 초
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatEngagedDuration = (seconds) => {
|
||||
const value = Number(seconds) || 0
|
||||
if (value < 60) {
|
||||
return `${value}초`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60)
|
||||
const remainSeconds = value % 60
|
||||
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 집계 단위 표시명을 반환한다.
|
||||
* @returns {string} 집계 단위 라벨
|
||||
*/
|
||||
const getSelectedPeriodLabel = () => {
|
||||
return periodOptions.find((option) => option.value === selectedPeriod.value)?.label || '일간'
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 그룹 표시명을 반환한다.
|
||||
* @param {string} sourceGroup - 유입 그룹
|
||||
* @returns {string} 표시명
|
||||
*/
|
||||
const getTrafficGroupLabel = (sourceGroup) => {
|
||||
if (sourceGroup === 'search') {
|
||||
return '검색'
|
||||
}
|
||||
|
||||
if (sourceGroup === 'sns') {
|
||||
return 'SNS'
|
||||
}
|
||||
|
||||
if (sourceGroup === 'direct') {
|
||||
return '직접'
|
||||
}
|
||||
|
||||
return '기타'
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 그룹별 조회수를 합산한다.
|
||||
* @param {string[]} groups - 유입 그룹
|
||||
* @returns {number} 조회수 합계
|
||||
*/
|
||||
const getSourceGroupViews = (groups) => {
|
||||
return (analytics.value.sources || [])
|
||||
.filter((source) => groups.includes(source.sourceGroup))
|
||||
.reduce((sum, source) => sum + Number(source.pageViews || 0), 0)
|
||||
}
|
||||
|
||||
const searchViews = computed(() => getSourceGroupViews(['search']))
|
||||
const snsViews = computed(() => getSourceGroupViews(['sns']))
|
||||
const otherViews = computed(() => getSourceGroupViews(['direct', 'other']))
|
||||
const mobileDevice = computed(() => (analytics.value.devices || []).find((row) => row.deviceType === '모바일'))
|
||||
const pcDevice = computed(() => (analytics.value.devices || []).find((row) => row.deviceType === 'PC'))
|
||||
const totalDevicePageViews = computed(() => {
|
||||
return (analytics.value.devices || []).reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 디바이스 비율을 반환한다.
|
||||
* @param {number} value - 조회수
|
||||
* @returns {string} 비율
|
||||
*/
|
||||
const formatDevicePercent = (value) => {
|
||||
const total = totalDevicePageViews.value
|
||||
if (!total) {
|
||||
return '0.0%'
|
||||
}
|
||||
|
||||
return `${((Number(value || 0) / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-post-analytics bg-paper p-6">
|
||||
<div class="admin-post-analytics__updated mb-3 text-right text-sm text-muted">
|
||||
{{ formatAdminDateTime(analytics.updatedAt) }} 기준
|
||||
</div>
|
||||
|
||||
<article class="admin-post-analytics__panel border border-line bg-white px-8 py-8">
|
||||
<header class="admin-post-analytics__header grid gap-6 lg:grid-cols-[minmax(0,1fr)_240px] lg:items-start">
|
||||
<div class="min-w-0">
|
||||
<NuxtLink class="admin-post-analytics__back mb-6 inline-flex text-sm font-semibold text-muted hover:text-ink" to="/admin/posts">
|
||||
< 글 목록
|
||||
</NuxtLink>
|
||||
<div class="admin-post-analytics__title-row flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 class="admin-post-analytics__title min-w-0 text-3xl font-semibold leading-tight text-black">
|
||||
{{ post.title || '(제목 없음)' }}
|
||||
</h1>
|
||||
<NuxtLink
|
||||
v-if="post.slug"
|
||||
:to="`/post/${post.slug}`"
|
||||
class="admin-post-analytics__public-link text-muted hover:text-ink"
|
||||
target="_blank"
|
||||
aria-label="공개 게시물 보기"
|
||||
>
|
||||
↗
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="admin-post-analytics__meta mt-3 text-sm font-medium text-muted">
|
||||
댓글 {{ formatStatNumber(post.commentCount) }}
|
||||
<span class="mx-1 text-[#c8ced3]">·</span>
|
||||
작성일 {{ formatAdminDate(post.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-post-analytics__total text-left lg:text-right">
|
||||
<strong class="block text-4xl font-bold text-[#333]">
|
||||
{{ formatStatNumber(analytics.total.views) }}
|
||||
</strong>
|
||||
<p class="mt-2 text-sm font-semibold text-muted">
|
||||
누적 조회수
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="admin-post-analytics__chart-section mt-16">
|
||||
<div class="admin-post-analytics__chart-header flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 class="admin-post-analytics__chart-title text-2xl font-semibold text-black">
|
||||
{{ getSelectedPeriodLabel() }} 통계
|
||||
</h2>
|
||||
|
||||
<div class="admin-post-analytics__controls flex flex-wrap gap-4">
|
||||
<div class="admin-post-analytics__segmented inline-flex border border-line">
|
||||
<button
|
||||
v-for="metric in metricOptions"
|
||||
:key="metric.value"
|
||||
type="button"
|
||||
class="admin-post-analytics__segment h-10 border-r border-line px-5 text-sm font-semibold last:border-r-0"
|
||||
:class="selectedMetric === metric.value ? 'bg-[#666] text-white' : 'bg-white text-muted hover:bg-paper hover:text-ink'"
|
||||
@click="selectedMetric = metric.value"
|
||||
>
|
||||
{{ metric.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-post-analytics__segmented inline-flex border border-line">
|
||||
<button
|
||||
v-for="option in periodOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="admin-post-analytics__segment h-10 border-r border-line px-5 text-sm font-semibold last:border-r-0"
|
||||
:class="selectedPeriod === option.value ? 'bg-[#666] text-white' : 'bg-white text-muted hover:bg-paper hover:text-ink'"
|
||||
@click="selectedPeriod = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminAnalyticsTrendChart
|
||||
class="admin-post-analytics__chart mt-8"
|
||||
:rows="analytics.trends"
|
||||
:period="selectedPeriod"
|
||||
:metric-key="selectedMetric"
|
||||
:metric-label="selectedMetric === 'views' ? '조회수' : '방문자'"
|
||||
:format-value="formatStatNumber"
|
||||
v-model:page-offset="analyticsChartPageOffset"
|
||||
bar-height-class="h-72"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="admin-post-analytics__summary mt-20 grid gap-6 border-t border-line pt-8 lg:grid-cols-4">
|
||||
<article class="admin-post-analytics__summary-card">
|
||||
<div class="flex items-center justify-between border-b border-[#e5e8eb] pb-3">
|
||||
<h3 class="text-sm font-bold text-[#666]">
|
||||
검색
|
||||
</h3>
|
||||
<span class="text-sm text-muted">{{ formatStatNumber(searchViews) }}</span>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-muted">
|
||||
<li
|
||||
v-for="row in analytics.sources.filter((source) => source.sourceGroup === 'search')"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex justify-between gap-3"
|
||||
>
|
||||
<span>{{ row.sourceName }}</span>
|
||||
<span>{{ formatStatNumber(row.pageViews) }}</span>
|
||||
</li>
|
||||
<li v-if="!analytics.sources.some((source) => source.sourceGroup === 'search')" class="flex justify-between gap-3">
|
||||
<span>검색 유입</span>
|
||||
<span>0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="admin-post-analytics__summary-card">
|
||||
<div class="flex items-center justify-between border-b border-[#e5e8eb] pb-3">
|
||||
<h3 class="text-sm font-bold text-[#666]">
|
||||
SNS
|
||||
</h3>
|
||||
<span class="text-sm text-muted">{{ formatStatNumber(snsViews) }}</span>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-muted">
|
||||
<li
|
||||
v-for="row in analytics.sources.filter((source) => source.sourceGroup === 'sns')"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex justify-between gap-3"
|
||||
>
|
||||
<span>{{ row.sourceName }}</span>
|
||||
<span>{{ formatStatNumber(row.pageViews) }}</span>
|
||||
</li>
|
||||
<li v-if="!analytics.sources.some((source) => source.sourceGroup === 'sns')" class="flex justify-between gap-3">
|
||||
<span>SNS 유입</span>
|
||||
<span>0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="admin-post-analytics__summary-card">
|
||||
<div class="flex items-center justify-between border-b border-[#b8c0c8] pb-3">
|
||||
<h3 class="text-sm font-bold text-[#666]">
|
||||
기타
|
||||
</h3>
|
||||
<span class="text-sm text-muted">{{ formatStatNumber(otherViews) }}</span>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-muted">
|
||||
<li
|
||||
v-for="row in analytics.sources.filter((source) => ['direct', 'other'].includes(source.sourceGroup))"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex justify-between gap-3"
|
||||
>
|
||||
<span>{{ getTrafficGroupLabel(row.sourceGroup) }}</span>
|
||||
<span>{{ formatStatNumber(row.pageViews) }}</span>
|
||||
</li>
|
||||
<li v-if="!analytics.sources.some((source) => ['direct', 'other'].includes(source.sourceGroup))" class="flex justify-between gap-3">
|
||||
<span>기타 유입</span>
|
||||
<span>0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="admin-post-analytics__summary-card">
|
||||
<div class="border-b border-[#b8c0c8] pb-3">
|
||||
<h3 class="text-sm font-bold text-[#666]">
|
||||
디바이스
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="mt-4 space-y-2 text-sm text-muted">
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>모바일</span>
|
||||
<span>{{ formatDevicePercent(mobileDevice?.pageViews) }}</span>
|
||||
</li>
|
||||
<li class="flex justify-between gap-3">
|
||||
<span>PC</span>
|
||||
<span>{{ formatDevicePercent(pcDevice?.pageViews) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="admin-post-analytics__keywords mt-20">
|
||||
<h3 class="text-sm font-bold text-[#666]">
|
||||
유입 키워드
|
||||
</h3>
|
||||
<ul
|
||||
v-if="analytics.keywords.length"
|
||||
class="mt-4 grid gap-2 md:grid-cols-2"
|
||||
>
|
||||
<li
|
||||
v-for="row in analytics.keywords"
|
||||
:key="`${row.sourceName}-${row.keyword}`"
|
||||
class="flex items-center justify-between gap-3 border border-line px-3 py-2 text-sm text-muted"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold text-ink">{{ row.keyword }}</span>
|
||||
<span class="shrink-0">{{ row.sourceName }} · {{ formatStatNumber(row.pageViews) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else
|
||||
class="mt-4 text-sm text-muted"
|
||||
>
|
||||
검색 유입 키워드가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-post-analytics__engagement mt-12 grid gap-3 sm:grid-cols-4">
|
||||
<article class="border border-line bg-paper p-4">
|
||||
<p class="text-xs font-semibold uppercase text-muted">읽음</p>
|
||||
<strong class="mt-1 block text-xl text-ink">{{ formatStatNumber(analytics.period.reads) }}</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-4">
|
||||
<p class="text-xs font-semibold uppercase text-muted">평균 체류</p>
|
||||
<strong class="mt-1 block text-xl text-ink">{{ formatEngagedDuration(analytics.period.avgEngagedSeconds) }}</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-4">
|
||||
<p class="text-xs font-semibold uppercase text-muted">50% 스크롤</p>
|
||||
<strong class="mt-1 block text-xl text-ink">{{ formatStatNumber(analytics.period.scroll50) }}</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-4">
|
||||
<p class="text-xs font-semibold uppercase text-muted">75/100%</p>
|
||||
<strong class="mt-1 block text-xl text-ink">{{ formatStatNumber(analytics.period.scroll75) }} / {{ formatStatNumber(analytics.period.scroll100) }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -15,6 +15,13 @@ const tagFilter = ref('all')
|
||||
const featuredFilter = ref('all')
|
||||
const sortOrder = ref('newest')
|
||||
const searchQuery = ref('')
|
||||
const analyticsModalPost = ref(null)
|
||||
const analyticsModalData = ref(createDefaultPostAnalytics())
|
||||
const analyticsModalPeriod = ref('daily')
|
||||
const analyticsModalMetric = ref('views')
|
||||
const analyticsModalChartPageOffset = ref(0)
|
||||
const analyticsModalLoading = ref(false)
|
||||
const analyticsModalError = ref('')
|
||||
const postMenuTriggerRefs = new Map()
|
||||
|
||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||
@@ -30,6 +37,113 @@ const { data: siteSettings } = await useFetch('/admin/api/settings', {
|
||||
})
|
||||
|
||||
const defaultTagColor = '#15171a'
|
||||
const analyticsPeriodOptions = [
|
||||
{ label: '일간', value: 'daily', days: 14 },
|
||||
{ label: '주간', value: 'weekly', days: 84 },
|
||||
{ label: '월간', value: 'monthly', days: 365 }
|
||||
]
|
||||
|
||||
const analyticsMetricOptions = [
|
||||
{ label: '조회수', value: 'views' },
|
||||
{ label: '방문자', value: 'visitors' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 게시물별 통계 기본값을 생성한다.
|
||||
* @returns {Object} 게시물별 통계 기본값
|
||||
*/
|
||||
function createDefaultPostAnalytics() {
|
||||
return {
|
||||
updatedAt: '',
|
||||
total: {
|
||||
views: 0,
|
||||
visitors: 0,
|
||||
reads: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50: 0,
|
||||
scroll75: 0,
|
||||
scroll100: 0
|
||||
},
|
||||
period: {
|
||||
views: 0,
|
||||
visitors: 0,
|
||||
reads: 0,
|
||||
avgEngagedSeconds: 0,
|
||||
scroll50: 0,
|
||||
scroll75: 0,
|
||||
scroll100: 0
|
||||
},
|
||||
trends: [],
|
||||
sources: [],
|
||||
devices: [],
|
||||
keywords: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 관리자 목록 표시 형식으로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAdminNumber = (value) => {
|
||||
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
|
||||
* @param {string | null} value - 날짜 값
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatAdminDateTimeLabel = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
|
||||
return `${year}.${month}.${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 초 단위 체류시간을 표시 문자열로 변환한다.
|
||||
* @param {number} seconds - 초
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatEngagedDuration = (seconds) => {
|
||||
const value = Number(seconds) || 0
|
||||
if (value < 60) {
|
||||
return `${value}초`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60)
|
||||
const remainSeconds = value % 60
|
||||
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 통계 기간 표시명을 반환한다.
|
||||
* @returns {string} 기간 표시명
|
||||
*/
|
||||
const getAnalyticsPeriodLabel = () => {
|
||||
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.label || '일간'
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 통계 기간 조회 일수를 반환한다.
|
||||
* @returns {number} 조회 일수
|
||||
*/
|
||||
const getAnalyticsPeriodDays = () => {
|
||||
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.days || 14
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 발행일 라벨을 반환한다.
|
||||
@@ -248,6 +362,142 @@ const filteredPosts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 게시물 통계 모달이 열려 있는지 */
|
||||
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
|
||||
|
||||
/** 통계 모달 검색 유입 조회수 */
|
||||
const analyticsModalSearchViews = computed(() => getAnalyticsSourceGroupViews(['search']))
|
||||
|
||||
/** 통계 모달 SNS 유입 조회수 */
|
||||
const analyticsModalSnsViews = computed(() => getAnalyticsSourceGroupViews(['sns']))
|
||||
|
||||
/** 통계 모달 기타 유입 조회수 */
|
||||
const analyticsModalOtherViews = computed(() => getAnalyticsSourceGroupViews(['direct', 'other']))
|
||||
|
||||
/** 통계 모달 모바일 디바이스 행 */
|
||||
const analyticsModalMobileDevice = computed(() => {
|
||||
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === '모바일')
|
||||
})
|
||||
|
||||
/** 통계 모달 PC 디바이스 행 */
|
||||
const analyticsModalPcDevice = computed(() => {
|
||||
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === 'PC')
|
||||
})
|
||||
|
||||
/** 통계 모달 디바이스 총 조회수 */
|
||||
const analyticsModalTotalDevicePageViews = computed(() => {
|
||||
return (analyticsModalData.value.devices || []).reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 유입 그룹 표시명을 반환한다.
|
||||
* @param {string} sourceGroup - 유입 그룹
|
||||
* @returns {string} 표시명
|
||||
*/
|
||||
const getTrafficGroupLabel = (sourceGroup) => {
|
||||
if (sourceGroup === 'search') {
|
||||
return '검색'
|
||||
}
|
||||
|
||||
if (sourceGroup === 'sns') {
|
||||
return 'SNS'
|
||||
}
|
||||
|
||||
if (sourceGroup === 'direct') {
|
||||
return '직접'
|
||||
}
|
||||
|
||||
return '기타'
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입 그룹별 조회수를 합산한다.
|
||||
* @param {string[]} groups - 유입 그룹
|
||||
* @returns {number} 조회수 합계
|
||||
*/
|
||||
const getAnalyticsSourceGroupViews = (groups) => {
|
||||
return (analyticsModalData.value.sources || [])
|
||||
.filter((source) => groups.includes(source.sourceGroup))
|
||||
.reduce((sum, source) => sum + Number(source.pageViews || 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스 조회 비율을 반환한다.
|
||||
* @param {number} value - 조회수
|
||||
* @returns {string} 비율
|
||||
*/
|
||||
const formatAnalyticsDevicePercent = (value) => {
|
||||
const total = analyticsModalTotalDevicePageViews.value
|
||||
if (!total) {
|
||||
return '0.0%'
|
||||
}
|
||||
|
||||
return `${((Number(value || 0) / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 통계 모달 데이터를 조회한다.
|
||||
* @returns {Promise<void>} 조회 완료
|
||||
*/
|
||||
const fetchAnalyticsModalData = async () => {
|
||||
if (!analyticsModalPost.value?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
analyticsModalLoading.value = true
|
||||
analyticsModalError.value = ''
|
||||
|
||||
try {
|
||||
analyticsModalData.value = await $fetch(`/admin/api/analytics/posts/${analyticsModalPost.value.id}`, {
|
||||
query: {
|
||||
period: analyticsModalPeriod.value,
|
||||
days: getAnalyticsPeriodDays()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
analyticsModalData.value = createDefaultPostAnalytics()
|
||||
analyticsModalError.value = error?.data?.message || '게시물 통계를 불러오지 못했습니다.'
|
||||
} finally {
|
||||
analyticsModalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 통계 모달을 연다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {Promise<void>} 모달 열기 완료
|
||||
*/
|
||||
const openAnalyticsModal = async (post) => {
|
||||
closePostMenu()
|
||||
analyticsModalPost.value = post
|
||||
analyticsModalPeriod.value = 'daily'
|
||||
analyticsModalMetric.value = 'views'
|
||||
analyticsModalChartPageOffset.value = 0
|
||||
analyticsModalData.value = createDefaultPostAnalytics()
|
||||
await fetchAnalyticsModalData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 통계 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeAnalyticsModal = () => {
|
||||
analyticsModalPost.value = null
|
||||
analyticsModalError.value = ''
|
||||
analyticsModalData.value = createDefaultPostAnalytics()
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 모달 집계 단위를 변경한다.
|
||||
* @param {string} period - 집계 단위
|
||||
* @returns {Promise<void>} 기간 변경 완료
|
||||
*/
|
||||
const selectAnalyticsModalPeriod = async (period) => {
|
||||
analyticsModalPeriod.value = period
|
||||
analyticsModalChartPageOffset.value = 0
|
||||
await fetchAnalyticsModalData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 수정 API 본문을 구성한다.
|
||||
* @param {Object} post - 게시물
|
||||
@@ -351,6 +601,17 @@ const onPostMenuDocumentPointerDown = (event) => {
|
||||
closePostMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 키 입력을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDocumentKeydown = (event) => {
|
||||
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
|
||||
closeAnalyticsModal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 추천 여부를 토글한다.
|
||||
* @param {Object} post - 게시물
|
||||
@@ -403,12 +664,14 @@ const deletePost = async (post) => {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
window.addEventListener('resize', updatePostMenuPosition)
|
||||
window.addEventListener('scroll', updatePostMenuPosition, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
window.removeEventListener('resize', updatePostMenuPosition)
|
||||
window.removeEventListener('scroll', updatePostMenuPosition, true)
|
||||
})
|
||||
@@ -531,6 +794,7 @@ watch(openPostMenuId, async (postId) => {
|
||||
<th class="admin-posts__cell px-4 py-3">제목</th>
|
||||
<th class="admin-posts__cell px-4 py-3">상태</th>
|
||||
<th class="admin-posts__cell px-4 py-3">태그</th>
|
||||
<th class="admin-posts__cell px-4 py-3">조회수</th>
|
||||
<th class="admin-posts__cell px-4 py-3">발행일</th>
|
||||
<th class="admin-posts__cell admin-posts__cell-actions w-12 px-2 py-3 text-right">
|
||||
<span class="sr-only">관리</span>
|
||||
@@ -596,6 +860,19 @@ watch(openPostMenuId, async (postId) => {
|
||||
</div>
|
||||
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
||||
</td>
|
||||
<td class="admin-posts__cell admin-posts__cell-views px-4 py-4">
|
||||
<p class="admin-posts__view-count text-[#394047]">
|
||||
{{ formatAdminNumber(post.totalViews) }}
|
||||
</p>
|
||||
<button
|
||||
class="admin-posts__analytics-link mt-1 inline-flex text-xs text-muted transition-colors hover:text-[#2f6feb]"
|
||||
type="button"
|
||||
@pointerdown.stop
|
||||
@click.stop="openAnalyticsModal(post)"
|
||||
>
|
||||
통계
|
||||
</button>
|
||||
</td>
|
||||
<td class="admin-posts__cell px-4 py-4">
|
||||
<p class="admin-posts__publish-date text-[#394047]">
|
||||
{{ getPublishDateLabel(post) }}
|
||||
@@ -672,5 +949,239 @@ watch(openPostMenuId, async (postId) => {
|
||||
<p v-if="filteredPosts.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||||
{{ posts.length === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
|
||||
</p>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isAnalyticsModalOpen"
|
||||
class="admin-posts__analytics-modal fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-posts-analytics-modal-title"
|
||||
@pointerdown.self="closeAnalyticsModal"
|
||||
>
|
||||
<article class="admin-posts__analytics-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
|
||||
<header class="admin-posts__analytics-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
게시물 통계
|
||||
</p>
|
||||
<h2 id="admin-posts-analytics-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
|
||||
{{ analyticsModalPost?.title || '(제목 없음)' }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ formatAdminDateTimeLabel(analyticsModalData.updatedAt) }} 기준
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="admin-posts__analytics-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
||||
type="button"
|
||||
@click="closeAnalyticsModal"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-posts__analytics-modal-body space-y-4 p-5">
|
||||
<p v-if="analyticsModalLoading" class="text-sm text-muted">
|
||||
게시물 통계를 불러오는 중입니다.
|
||||
</p>
|
||||
<p v-else-if="analyticsModalError" class="text-sm text-red-600">
|
||||
{{ analyticsModalError }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="admin-posts__analytics-modal-summary grid gap-3 sm:grid-cols-4">
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
누적 조회수
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.total.views) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
방문자
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.visitors) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
평균 체류
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatEngagedDuration(analyticsModalData.period.avgEngagedSeconds) }}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="border border-line bg-paper p-3">
|
||||
<p class="text-[11px] font-semibold uppercase text-muted">
|
||||
50% 스크롤
|
||||
</p>
|
||||
<strong class="mt-1 block text-lg text-ink">
|
||||
{{ formatAdminNumber(analyticsModalData.period.scroll50) }}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section class="admin-posts__analytics-modal-chart border border-line bg-paper p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
통계 추이
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ getAnalyticsPeriodLabel() }} 기준
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="inline-flex border border-line bg-white">
|
||||
<button
|
||||
v-for="metric in analyticsMetricOptions"
|
||||
:key="metric.value"
|
||||
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
|
||||
:class="analyticsModalMetric === metric.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
|
||||
type="button"
|
||||
@click="analyticsModalMetric = metric.value"
|
||||
>
|
||||
{{ metric.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="inline-flex border border-line bg-white">
|
||||
<button
|
||||
v-for="option in analyticsPeriodOptions"
|
||||
:key="option.value"
|
||||
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
|
||||
:class="analyticsModalPeriod === option.value ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-paper hover:text-ink'"
|
||||
type="button"
|
||||
@click="selectAnalyticsModalPeriod(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminAnalyticsTrendChart
|
||||
class="mt-4"
|
||||
:rows="analyticsModalData.trends"
|
||||
:period="analyticsModalPeriod"
|
||||
:metric-key="analyticsModalMetric"
|
||||
:metric-label="analyticsModalMetric === 'views' ? '조회수' : '방문자'"
|
||||
:format-value="formatAdminNumber"
|
||||
v-model:page-offset="analyticsModalChartPageOffset"
|
||||
compact
|
||||
bar-height-class="h-36"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
검색
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
조회 {{ formatAdminNumber(analyticsModalSearchViews) }}
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li
|
||||
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'search')"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="font-medium text-ink">{{ row.sourceName }}</span>
|
||||
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'search')" class="mt-3 text-xs text-muted">
|
||||
해당 기간에 집계된 검색 유입이 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
SNS
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
조회 {{ formatAdminNumber(analyticsModalSnsViews) }}
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li
|
||||
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'sns')"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="font-medium text-ink">{{ row.sourceName }}</span>
|
||||
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'sns')" class="mt-3 text-xs text-muted">
|
||||
해당 기간에 집계된 SNS 유입이 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
기타
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
조회 {{ formatAdminNumber(analyticsModalOtherViews) }}
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li
|
||||
v-for="row in analyticsModalData.sources.filter((source) => ['direct', 'other'].includes(source.sourceGroup))"
|
||||
:key="`${row.sourceGroup}-${row.sourceName}`"
|
||||
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span class="font-medium text-ink">{{ getTrafficGroupLabel(row.sourceGroup) }} · {{ row.sourceName }}</span>
|
||||
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!analyticsModalData.sources.some((source) => ['direct', 'other'].includes(source.sourceGroup))" class="mt-3 text-xs text-muted">
|
||||
해당 기간에 집계된 기타 유입이 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
디바이스
|
||||
</h3>
|
||||
<ul class="mt-3 space-y-2">
|
||||
<li class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs">
|
||||
<span class="font-medium text-ink">모바일</span>
|
||||
<span>{{ formatAnalyticsDevicePercent(analyticsModalMobileDevice?.pageViews) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between gap-3 text-xs">
|
||||
<span class="font-medium text-ink">PC</span>
|
||||
<span>{{ formatAnalyticsDevicePercent(analyticsModalPcDevice?.pageViews) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4 lg:col-span-2">
|
||||
<h3 class="text-xs font-semibold uppercase text-muted">
|
||||
유입 키워드
|
||||
</h3>
|
||||
<ul v-if="analyticsModalData.keywords.length" class="mt-3 grid gap-2 md:grid-cols-2">
|
||||
<li
|
||||
v-for="row in analyticsModalData.keywords"
|
||||
:key="`${row.sourceName}-${row.keyword}`"
|
||||
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-ink">{{ row.keyword }}</span>
|
||||
<span class="shrink-0 text-right text-muted">{{ row.sourceName }} · {{ formatAdminNumber(row.pageViews) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mt-3 text-xs text-muted">
|
||||
해당 기간에 수집된 유입 키워드가 없습니다.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ const mapPostRow = (row) => ({
|
||||
showFeaturedImage: Boolean(row.show_featured_image),
|
||||
isFeatured: Boolean(row.is_featured),
|
||||
commentCount: Number(row.comment_count || 0),
|
||||
totalViews: Number(row.total_views || 0),
|
||||
seoTitle: row.seo_title || '',
|
||||
seoDescription: row.seo_description || '',
|
||||
canonicalUrl: row.canonical_url || '',
|
||||
@@ -80,6 +81,7 @@ const mapPageRow = (row) => ({
|
||||
renderMode: row.render_mode || 'markdown',
|
||||
status: row.status || 'published',
|
||||
featuredImage: row.featured_image,
|
||||
totalViews: Number(row.total_views || 0),
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
@@ -298,6 +300,11 @@ export const listPosts = async ({ includeMembership = false } = {}) => {
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
(
|
||||
SELECT COALESCE(SUM(post_analytics_daily.views), 0)::int
|
||||
FROM post_analytics_daily
|
||||
WHERE post_analytics_daily.post_id = posts.id
|
||||
) AS total_views,
|
||||
COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
@@ -338,6 +345,11 @@ export const listAdminPosts = async () => {
|
||||
WHERE comments.post_id = posts.id
|
||||
AND comments.status = 'published'
|
||||
) AS comment_count,
|
||||
(
|
||||
SELECT COALESCE(SUM(post_analytics_daily.views), 0)::int
|
||||
FROM post_analytics_daily
|
||||
WHERE post_analytics_daily.post_id = posts.id
|
||||
) AS total_views,
|
||||
COALESCE(array_agg(tags.slug ORDER BY post_tags.sort_order ASC, post_tags.created_at ASC, tags.name ASC) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||
FROM posts
|
||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||
@@ -595,7 +607,13 @@ export const listAdminPages = async () => {
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
SELECT
|
||||
pages.*,
|
||||
(
|
||||
SELECT COALESCE(SUM(page_analytics_daily.views), 0)::int
|
||||
FROM page_analytics_daily
|
||||
WHERE page_analytics_daily.page_id = pages.id
|
||||
) AS total_views
|
||||
FROM pages
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
26
server/routes/admin/api/analytics/pages/[id].get.js
Normal file
26
server/routes/admin/api/analytics/pages/[id].get.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createError, getQuery, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth.js'
|
||||
import { getPageAnalyticsDetail } from '../../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 페이지별 통계 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 페이지별 통계
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '페이지 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
const period = String(query.period || 'daily')
|
||||
|
||||
return getPageAnalyticsDetail({ pageId: id, days, period })
|
||||
})
|
||||
26
server/routes/admin/api/analytics/posts/[id].get.js
Normal file
26
server/routes/admin/api/analytics/posts/[id].get.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createError, getQuery, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth.js'
|
||||
import { getPostAnalyticsDetail } from '../../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 게시물별 통계 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 게시물별 통계
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '게시물 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
const period = String(query.period || 'daily')
|
||||
|
||||
return getPostAnalyticsDetail({ postId: id, days, period })
|
||||
})
|
||||
19
server/routes/admin/api/analytics/range-detail.get.js
Normal file
19
server/routes/admin/api/analytics/range-detail.get.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth.js'
|
||||
import { getAnalyticsRangeDetail } from '../../../../repositories/analytics-repository.js'
|
||||
|
||||
/**
|
||||
* 관리자 날짜별 통계 상세 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 날짜 상세
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
|
||||
return getAnalyticsRangeDetail({
|
||||
startDay: String(query.startDay || ''),
|
||||
endDay: String(query.endDay || ''),
|
||||
limit: Number(query.limit) || 5
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const query = getQuery(event)
|
||||
const days = Number(query.days) || 30
|
||||
const period = String(query.period || 'weekly')
|
||||
|
||||
return getAnalyticsSummary({ days })
|
||||
return getAnalyticsSummary({ days, period })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user