459 lines
20 KiB
Vue
459 lines
20 KiB
Vue
<template>
|
||
<div class="admin-dashboard bg-slate-50 min-h-screen text-slate-800 antialiased">
|
||
<!-- 1. 관리자 상단 네비게이션 헤더 -->
|
||
<header class="bg-slate-900 text-white sticky top-0 z-50 border-b border-slate-800 shadow-md">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<NuxtLink to="/" class="flex items-center gap-2.5 text-white no-underline">
|
||
<div class="w-9 h-9 rounded-xl bg-blue-600 flex items-center justify-center font-extrabold text-base shadow-sm">
|
||
{{ regionName.charAt(0) || 'A' }}
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="font-bold text-lg tracking-tight">{{ siteName }}</span>
|
||
<span class="text-[11px] font-semibold bg-blue-500/20 text-blue-300 px-2 py-0.5 rounded-full border border-blue-400/30">
|
||
{{ regionName }} 센터
|
||
</span>
|
||
</div>
|
||
<span class="text-xs text-slate-400">SEO 프로그래매틱 랜딩페이지 통합 관리자</span>
|
||
</div>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<NuxtLink to="/" class="hidden sm:inline-flex items-center gap-1.5 text-xs font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 px-3.5 py-2 rounded-lg border border-slate-700 transition no-underline">
|
||
<i class="fa-solid fa-house text-xs"></i>
|
||
<span>메인 홈 바로가기</span>
|
||
</NuxtLink>
|
||
<a :href="sitemapUrl" target="_blank" class="inline-flex items-center gap-1.5 text-xs font-bold bg-blue-600 hover:bg-blue-700 text-white px-3.5 py-2 rounded-lg shadow-sm transition no-underline">
|
||
<i class="fa-solid fa-sitemap text-xs"></i>
|
||
<span>sitemap.xml</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 2. 본문 컨테이너 -->
|
||
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||
|
||
<!-- 상단 요약 통계 카드 4종 -->
|
||
<section class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||
<div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||
<div class="flex items-center justify-between text-slate-400 mb-2">
|
||
<span class="text-xs font-bold tracking-wider uppercase text-slate-500">총 생성 페이지</span>
|
||
<i class="fa-solid fa-file-lines text-blue-600"></i>
|
||
</div>
|
||
<div class="text-2xl sm:text-3xl font-extrabold text-slate-900">{{ filteredPages.length.toLocaleString() }} <span class="text-xs font-normal text-slate-400">/ {{ allPages.length.toLocaleString() }}개</span></div>
|
||
<div class="text-xs text-emerald-600 font-semibold mt-1 flex items-center gap-1">
|
||
<i class="fa-solid fa-check-circle"></i>
|
||
<span>100% SSR 색인 최적화</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||
<div class="flex items-center justify-between text-slate-400 mb-2">
|
||
<span class="text-xs font-bold tracking-wider uppercase text-slate-500">등록 동/지역 수</span>
|
||
<i class="fa-solid fa-map-location-dot text-indigo-600"></i>
|
||
</div>
|
||
<div class="text-2xl sm:text-3xl font-extrabold text-slate-900">{{ uniqueDongs.length }} <span class="text-xs font-normal text-slate-400">개 동</span></div>
|
||
<div class="text-xs text-slate-500 font-medium mt-1">관할 {{ sigunguList.length }}개 시·군·구 전담</div>
|
||
</div>
|
||
|
||
<div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||
<div class="flex items-center justify-between text-slate-400 mb-2">
|
||
<span class="text-xs font-bold tracking-wider uppercase text-slate-500">과목 및 학년 조합</span>
|
||
<i class="fa-solid fa-graduation-cap text-purple-600"></i>
|
||
</div>
|
||
<div class="text-2xl sm:text-3xl font-extrabold text-slate-900">{{ subjects.length }} × {{ grades.length }} <span class="text-xs font-normal text-slate-400">= 15조합/동</span></div>
|
||
<div class="text-xs text-slate-500 font-medium mt-1">국·영·수·과·사 전 과목</div>
|
||
</div>
|
||
|
||
<div class="bg-white p-5 rounded-2xl border border-slate-200 shadow-xs">
|
||
<div class="flex items-center justify-between text-slate-400 mb-2">
|
||
<span class="text-xs font-bold tracking-wider uppercase text-slate-500">사이트 도메인</span>
|
||
<i class="fa-solid fa-globe text-emerald-600"></i>
|
||
</div>
|
||
<div class="text-base sm:text-lg font-extrabold text-slate-900 truncate" :title="siteDomain">{{ siteDomain.replace(/^https?:\/\//, '') }}</div>
|
||
<div class="text-xs text-blue-600 font-semibold mt-1">표준 Canonical 활성화</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 3. 실시간 필터 및 검색 컨트롤러 -->
|
||
<section class="bg-white rounded-2xl border border-slate-200 p-5 sm:p-6 mb-8 shadow-xs">
|
||
<div class="flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between mb-4">
|
||
<!-- 검색창 -->
|
||
<div class="relative flex-1">
|
||
<i class="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
placeholder="동 이름 검색 (예: 개포동, 대치동, 범어동...)"
|
||
class="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-600 focus:bg-white transition"
|
||
/>
|
||
<button
|
||
v-if="searchQuery"
|
||
@click="searchQuery = ''"
|
||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 text-xs"
|
||
>
|
||
<i class="fa-solid fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- URL 일괄 복사 버튼 -->
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
@click="copyAllUrls"
|
||
class="inline-flex items-center gap-1.5 px-4 py-2.5 bg-slate-800 hover:bg-slate-700 text-white text-xs font-bold rounded-xl transition shadow-xs"
|
||
>
|
||
<i class="fa-solid fa-copy"></i>
|
||
<span>{{ copyStatusText }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 필터 칩 그룹 -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3 border-t border-slate-100 text-xs">
|
||
<!-- 시군구 필터 -->
|
||
<div>
|
||
<label class="block font-bold text-slate-600 mb-1.5">시·군·구 선택</label>
|
||
<select
|
||
v-model="selectedSigungu"
|
||
class="w-full p-2 bg-slate-50 border border-slate-200 rounded-lg text-slate-700 text-xs focus:outline-none focus:ring-2 focus:ring-blue-600"
|
||
>
|
||
<option value="">전체 구·군 ({{ sigunguList.length }}개)</option>
|
||
<option v-for="gu in sigunguList" :key="gu" :value="gu">{{ gu }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 학년 필터 -->
|
||
<div>
|
||
<label class="block font-bold text-slate-600 mb-1.5">학년 선택</label>
|
||
<div class="flex gap-1.5">
|
||
<button
|
||
@click="selectedGrade = ''"
|
||
:class="selectedGrade === '' ? 'bg-blue-600 text-white font-bold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
|
||
class="flex-1 py-2 rounded-lg text-xs transition"
|
||
>
|
||
전체
|
||
</button>
|
||
<button
|
||
v-for="g in grades"
|
||
:key="g"
|
||
@click="selectedGrade = g"
|
||
:class="selectedGrade === g ? 'bg-blue-600 text-white font-bold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
|
||
class="flex-1 py-2 rounded-lg text-xs transition"
|
||
>
|
||
{{ g.replace('학생', '') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 과목 필터 -->
|
||
<div>
|
||
<label class="block font-bold text-slate-600 mb-1.5">과목 선택</label>
|
||
<div class="flex gap-1 flex-wrap">
|
||
<button
|
||
@click="selectedSubject = ''"
|
||
:class="selectedSubject === '' ? 'bg-blue-600 text-white font-bold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
|
||
class="px-2.5 py-2 rounded-lg text-xs transition"
|
||
>
|
||
전체
|
||
</button>
|
||
<button
|
||
v-for="sub in subjects"
|
||
:key="sub"
|
||
@click="selectedSubject = sub"
|
||
:class="selectedSubject === sub ? 'bg-blue-600 text-white font-bold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
|
||
class="px-2.5 py-2 rounded-lg text-xs transition"
|
||
>
|
||
{{ sub }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 4. 페이지 목록 테이블 (페이지네이션 적용) -->
|
||
<section class="bg-white rounded-2xl border border-slate-200 shadow-xs overflow-hidden">
|
||
<div class="p-4 sm:p-5 border-b border-slate-100 flex items-center justify-between flex-wrap gap-2">
|
||
<div class="flex items-center gap-2">
|
||
<h2 class="text-base font-bold text-slate-900">페이지 목록</h2>
|
||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 border border-blue-100">
|
||
{{ filteredPages.length.toLocaleString() }}개 검색됨
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||
<span>페이지당 표시:</span>
|
||
<select v-model="pageSize" class="p-1 bg-slate-50 border border-slate-200 rounded text-xs">
|
||
<option :value="30">30개</option>
|
||
<option :value="60">60개</option>
|
||
<option :value="120">120개</option>
|
||
<option :value="300">300개</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 테이블 레이아웃 -->
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-left text-xs sm:text-sm">
|
||
<thead class="bg-slate-50 text-slate-600 text-xs font-bold border-b border-slate-200 uppercase">
|
||
<tr>
|
||
<th class="py-3 px-4 w-12 text-center">No</th>
|
||
<th class="py-3 px-4">페이지 타이틀 (H1)</th>
|
||
<th class="py-3 px-4">지역 (구/동)</th>
|
||
<th class="py-3 px-4">학년</th>
|
||
<th class="py-3 px-4">과목</th>
|
||
<th class="py-3 px-4">URL 경로</th>
|
||
<th class="py-3 px-4 w-28 text-center">동작</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-slate-100">
|
||
<tr
|
||
v-for="(page, idx) in paginatedPages"
|
||
:key="page.path"
|
||
class="hover:bg-slate-50/80 transition"
|
||
>
|
||
<td class="py-3 px-4 text-center text-xs text-slate-400 font-mono">
|
||
{{ (currentPage - 1) * pageSize + idx + 1 }}
|
||
</td>
|
||
<td class="py-3 px-4 font-semibold text-slate-900">
|
||
<NuxtLink :to="page.path" target="_blank" class="hover:text-blue-600 transition flex items-center gap-1.5 no-underline text-slate-900">
|
||
<span>{{ page.dong }} {{ page.grade }} {{ page.subject }}과외</span>
|
||
<i class="fa-solid fa-arrow-up-right-from-square text-[10px] text-slate-400"></i>
|
||
</NuxtLink>
|
||
</td>
|
||
<td class="py-3 px-4 text-slate-600">
|
||
<span class="text-xs px-2 py-0.5 bg-slate-100 rounded text-slate-700 font-medium">
|
||
{{ page.sigungu }} {{ page.dong }}
|
||
</span>
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<span class="text-xs font-medium text-slate-700">{{ page.grade }}</span>
|
||
</td>
|
||
<td class="py-3 px-4">
|
||
<span
|
||
class="text-xs font-bold px-2 py-0.5 rounded-full"
|
||
:class="getSubjectBadgeClass(page.subject)"
|
||
>
|
||
{{ page.subject }}
|
||
</span>
|
||
</td>
|
||
<td class="py-3 px-4 font-mono text-xs text-slate-500 truncate max-w-[200px]" :title="page.path">
|
||
{{ page.path }}
|
||
</td>
|
||
<td class="py-3 px-4 text-center">
|
||
<div class="flex items-center justify-center gap-1">
|
||
<NuxtLink
|
||
:to="page.path"
|
||
target="_blank"
|
||
class="px-2.5 py-1.5 bg-blue-50 hover:bg-blue-100 text-blue-700 rounded-lg text-xs font-bold transition no-underline"
|
||
title="새 창에서 열기"
|
||
>
|
||
열기
|
||
</NuxtLink>
|
||
<button
|
||
@click="copyText(`${siteDomain}${page.path}`)"
|
||
class="px-2.5 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-xs transition"
|
||
title="URL 복사"
|
||
>
|
||
<i class="fa-solid fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="filteredPages.length === 0">
|
||
<td colspan="7" class="py-12 text-center text-slate-400">
|
||
<i class="fa-solid fa-folder-open text-3xl mb-2 text-slate-300"></i>
|
||
<p class="text-sm">조건에 일치하는 페이지가 없습니다.</p>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 5. 페이지네이션 컨트롤러 -->
|
||
<div v-if="totalPages > 1" class="p-4 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2 text-xs">
|
||
<div class="text-slate-500">
|
||
총 {{ totalPages }}페이지 중 <strong class="text-slate-800">{{ currentPage }}</strong>페이지 ({{ filteredPages.length }}개 중 {{ ((currentPage - 1) * pageSize) + 1 }} ~ {{ Math.min(currentPage * pageSize, filteredPages.length) }} 표시)
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<button
|
||
@click="currentPage = 1"
|
||
:disabled="currentPage === 1"
|
||
class="px-2.5 py-1.5 border border-slate-200 rounded-lg disabled:opacity-30 hover:bg-slate-50"
|
||
>
|
||
<i class="fa-solid fa-angles-left"></i>
|
||
</button>
|
||
<button
|
||
@click="currentPage--"
|
||
:disabled="currentPage === 1"
|
||
class="px-2.5 py-1.5 border border-slate-200 rounded-lg disabled:opacity-30 hover:bg-slate-50"
|
||
>
|
||
<i class="fa-solid fa-angle-left"></i>
|
||
</button>
|
||
|
||
<span class="px-3 py-1 font-bold text-slate-800">
|
||
{{ currentPage }} / {{ totalPages }}
|
||
</span>
|
||
|
||
<button
|
||
@click="currentPage++"
|
||
:disabled="currentPage === totalPages"
|
||
class="px-2.5 py-1.5 border border-slate-200 rounded-lg disabled:opacity-30 hover:bg-slate-50"
|
||
>
|
||
<i class="fa-solid fa-angle-right"></i>
|
||
</button>
|
||
<button
|
||
@click="currentPage = totalPages"
|
||
:disabled="currentPage === totalPages"
|
||
class="px-2.5 py-1.5 border border-slate-200 rounded-lg disabled:opacity-30 hover:bg-slate-50"
|
||
>
|
||
<i class="fa-solid fa-angles-right"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch } from 'vue'
|
||
import regionsData from '../data/regions.json'
|
||
|
||
const config = useRuntimeConfig()
|
||
const siteName = config.public?.siteName || '1:1 맞춤과외'
|
||
const siteDomain = config.public?.siteDomain || 'https://aeseo.cloud'
|
||
const regionName = regionsData.regionName || '서울'
|
||
|
||
const sitemapUrl = `${siteDomain}/sitemap.xml`
|
||
|
||
// SEO 기본 설정
|
||
useHead({
|
||
title: `[관리자] ${regionName} 전 페이지 목록 인덱스 | ${siteName}`,
|
||
meta: [
|
||
{ name: 'robots', content: 'noindex, nofollow' } // 관리자 페이지는 검색엔진 수집 제외
|
||
]
|
||
})
|
||
|
||
// 과목 및 학년 정의
|
||
const subjects = ['수학', '영어', '국어', '과학', '사회']
|
||
const grades = ['초등학생', '중학생', '고등학생']
|
||
|
||
// 고유 시군구 및 동 목록
|
||
const uniqueRegions = computed(() => {
|
||
return regionsData.list || []
|
||
})
|
||
|
||
const sigunguList = computed(() => {
|
||
const gus = uniqueRegions.value.map(r => r.sigungu)
|
||
return Array.from(new Set(gus)).sort()
|
||
})
|
||
|
||
const uniqueDongs = computed(() => {
|
||
const dongs = uniqueRegions.value.map(r => r.dong)
|
||
return Array.from(new Set(dongs))
|
||
})
|
||
|
||
// 모든 페이지 조합 생성 (동 × 학년 × 과목)
|
||
const allPages = computed(() => {
|
||
const pages = []
|
||
uniqueRegions.value.forEach(r => {
|
||
grades.forEach(grade => {
|
||
subjects.forEach(subject => {
|
||
pages.push({
|
||
sido: r.sido,
|
||
sigungu: r.sigungu,
|
||
dong: r.dong,
|
||
grade,
|
||
subject,
|
||
path: `/${encodeURIComponent(r.dong)}/${encodeURIComponent(grade)}/${encodeURIComponent(subject)}`
|
||
})
|
||
})
|
||
})
|
||
})
|
||
return pages
|
||
})
|
||
|
||
// 필터 상태
|
||
const searchQuery = ref('')
|
||
const selectedSigungu = ref('')
|
||
const selectedGrade = ref('')
|
||
const selectedSubject = ref('')
|
||
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(60)
|
||
|
||
// 필터링된 페이지
|
||
const filteredPages = computed(() => {
|
||
const q = searchQuery.value.trim().toLowerCase()
|
||
const gu = selectedSigungu.value
|
||
const g = selectedGrade.value
|
||
const sub = selectedSubject.value
|
||
|
||
return allPages.value.filter(p => {
|
||
if (gu && p.sigungu !== gu) return false
|
||
if (g && p.grade !== g) return false
|
||
if (sub && p.subject !== sub) return false
|
||
if (q) {
|
||
const matchDong = p.dong.toLowerCase().includes(q)
|
||
const matchGu = p.sigungu.toLowerCase().includes(q)
|
||
const matchSub = p.subject.toLowerCase().includes(q)
|
||
if (!matchDong && !matchGu && !matchSub) return false
|
||
}
|
||
return true
|
||
})
|
||
})
|
||
|
||
// 필터 변경 시 첫 페이지로 리셋
|
||
watch([searchQuery, selectedSigungu, selectedGrade, selectedSubject, pageSize], () => {
|
||
currentPage.value = 1
|
||
})
|
||
|
||
// 페이지네이션
|
||
const totalPages = computed(() => {
|
||
return Math.ceil(filteredPages.value.length / pageSize.value) || 1
|
||
})
|
||
|
||
const paginatedPages = computed(() => {
|
||
const start = (currentPage.value - 1) * pageSize.value
|
||
return filteredPages.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
// 과목별 뱃지 스타일
|
||
function getSubjectBadgeClass(subject) {
|
||
switch (subject) {
|
||
case '수학': return 'bg-blue-50 text-blue-700 border border-blue-200'
|
||
case '영어': return 'bg-purple-50 text-purple-700 border border-purple-200'
|
||
case '국어': return 'bg-amber-50 text-amber-700 border border-amber-200'
|
||
case '과학': return 'bg-emerald-50 text-emerald-700 border border-emerald-200'
|
||
case '사회': return 'bg-rose-50 text-rose-700 border border-rose-200'
|
||
default: return 'bg-slate-100 text-slate-700'
|
||
}
|
||
}
|
||
|
||
// 클립보드 복사
|
||
const copyStatusText = ref('검색된 URL 일괄 복사')
|
||
function copyAllUrls() {
|
||
const urls = filteredPages.value.map(p => `${siteDomain}${p.path}`).join('\n')
|
||
copyText(urls)
|
||
copyStatusText.value = `총 ${filteredPages.value.length}개 복사 완료!`
|
||
setTimeout(() => {
|
||
copyStatusText.value = '검색된 URL 일괄 복사'
|
||
}, 2500)
|
||
}
|
||
|
||
function copyText(text) {
|
||
if (navigator && navigator.clipboard) {
|
||
navigator.clipboard.writeText(text)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.admin-dashboard {
|
||
font-family: inherit;
|
||
}
|
||
</style>
|