Files
web_6ea_educo/ulsan/pages/admin.vue
T
2026-10-20 14:14:02 +09:00

459 lines
20 KiB
Vue
Raw Blame History

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