해당 admin 페이지 완료

This commit is contained in:
2026-10-20 14:14:02 +09:00
parent 24c2ceefa2
commit f904660049
8 changed files with 3664 additions and 0 deletions
+458
View File
@@ -0,0 +1,458 @@
<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>