tracuusapnhap / index.html
CVNSS's picture
Update index.html
12c50c0 verified
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Cổng Tra Cứu Đơn Vị Hành Chính</title>
<meta name="description" content="Hệ thống tra cứu thông tin sáp nhập đơn vị hành chính cấp xã/phường giai đoạn mới.">
<!-- Thư viện xử lý YAML -->
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<!-- Google Fonts: Merriweather (Tiêu đề trang trọng), Roboto (Nội dung hiện đại) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700;900&family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
/* Bảng màu Hành chính Pháp luật */
--gov-red: #d60000; /* Đỏ cờ */
--gov-red-dark: #9b0000; /* Đỏ thẫm */
--gov-gold: #ffda00; /* Vàng sao */
--gov-gold-light: #fff59d; /* Vàng nhạt nền */
--text-primary: #2d2d2d; /* Đen văn bản */
--text-secondary: #555555; /* Xám ghi */
--bg-body: #f8f9fa; /* Xám nhạt toàn trang */
--bg-paper: #ffffff; /* Trắng giấy */
--border-color: #e0e0e0;
--shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--radius: 4px; /* Bo góc nhẹ, cứng cáp */
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
line-height: 1.6;
font-size: 16px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* --- Header: Phong cách Quốc huy/Cờ --- */
header {
background: linear-gradient(to bottom, var(--gov-red), var(--gov-red-dark));
color: var(--gov-gold);
padding: 1.5rem 1rem;
text-align: center;
border-bottom: 5px solid var(--gov-gold);
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
position: relative;
}
.header-inner {
max-width: 960px;
margin: 0 auto;
position: relative;
z-index: 2;
}
h1 {
font-family: 'Merriweather', serif;
text-transform: uppercase;
font-weight: 900;
font-size: 1.5rem;
margin: 0;
letter-spacing: 1px;
line-height: 1.4;
text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
}
.subtitle {
font-family: 'Roboto', sans-serif;
font-weight: 300;
font-size: 0.95rem;
margin-top: 0.5rem;
color: #ffffff;
opacity: 0.9;
}
/* --- Main Content --- */
main {
flex: 1;
width: 100%;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
/* --- Card Styles --- */
.card {
background: var(--bg-paper);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--border-color);
overflow: hidden;
}
.card-header {
background-color: #fcfcfc;
border-bottom: 2px solid var(--gov-red);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-family: 'Merriweather', serif;
font-weight: 700;
color: var(--gov-red-dark);
margin: 0;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title::before {
content: '';
display: block;
width: 4px;
height: 1.1rem;
background: var(--gov-red);
border-radius: 2px;
}
.card-body {
padding: 1.5rem;
}
/* --- Tabs --- */
.tabs {
display: flex;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.tab-btn {
padding: 0.75rem 1.5rem;
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
border: none;
background: none;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
font-family: 'Roboto', sans-serif;
text-transform: uppercase;
font-size: 0.85rem;
}
.tab-btn:hover {
color: var(--gov-red);
background-color: #fffbfb;
}
.tab-btn.active {
color: var(--gov-red-dark);
border-bottom-color: var(--gov-red);
}
/* --- Form Elements --- */
.form-group {
margin-bottom: 1.25rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
font-size: 0.95rem;
}
select, input[type="text"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: var(--radius);
font-size: 1rem;
font-family: inherit;
background-color: #fff;
transition: border-color 0.2s;
}
select:focus, input[type="text"]:focus {
outline: none;
border-color: var(--gov-red);
box-shadow: 0 0 0 3px rgba(214, 0, 0, 0.1);
}
select:disabled {
background-color: #f5f5f5;
color: #999;
cursor: not-allowed;
}
.btn-container {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
button {
padding: 0.75rem 1.5rem;
font-size: 0.95rem;
font-weight: 700;
text-transform: uppercase;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
font-family: 'Roboto', sans-serif;
}
.btn-primary {
background-color: var(--gov-red);
color: white;
box-shadow: 0 2px 4px rgba(214, 0, 0, 0.2);
}
.btn-primary:hover {
background-color: var(--gov-red-dark);
}
.btn-secondary {
background-color: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background-color: #d0d0d0;
}
/* --- Results Area --- */
.result-area {
margin-top: 1.5rem;
background-color: #fffff0; /* Màu giấy vàng nhạt cổ điển */
border: 1px solid #e6dbb9;
border-left: 4px solid var(--gov-gold-dark);
padding: 1.25rem;
border-radius: var(--radius);
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.result-title {
font-family: 'Merriweather', serif;
font-weight: 700;
color: var(--gov-red-dark);
margin-top: 0;
margin-bottom: 1rem;
border-bottom: 1px dashed #ccc;
padding-bottom: 0.5rem;
font-size: 1.1rem;
}
.result-row {
display: flex;
margin-bottom: 0.75rem;
align-items: baseline;
}
.result-label {
width: 140px;
font-weight: 600;
color: #666;
flex-shrink: 0;
font-size: 0.9rem;
}
.result-val {
flex: 1;
font-weight: 500;
color: #222;
}
.highlight {
color: var(--gov-red);
font-weight: 700;
}
/* --- Footer --- */
footer {
background-color: #333;
color: #ddd;
text-align: center;
padding: 1.5rem;
font-size: 0.85rem;
border-top: 4px solid var(--gov-red);
}
/* --- Status Bar --- */
.status-bar {
background-color: #fff3cd;
color: #856404;
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1.5rem;
border: 1px solid #ffeeba;
display: flex;
align-items: center;
font-size: 0.9rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #ccc;
margin-right: 10px;
display: inline-block;
}
.status-dot.active { background-color: #28a745; box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.2); }
.status-dot.error { background-color: #dc3545; }
.status-dot.loading { background-color: var(--gov-gold); animation: pulse 1s infinite; }
@keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } 100% { opacity: 1; } }
/* --- Responsive --- */
@media (max-width: 600px) {
h1 { font-size: 1.2rem; }
.result-row { flex-direction: column; }
.result-label { width: 100%; margin-bottom: 2px; }
.btn-container { flex-direction: column; }
button { width: 100%; }
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<h1>Cổng Thông Tin Tra Cứu<br>Đơn Vị Hành Chính</h1>
<div class="subtitle">Phục vụ công tác chuyển đổi số và quản lý địa giới hành chính</div>
</div>
</header>
<main>
<!-- Trạng thái hệ thống -->
<div id="systemStatus" class="status-bar">
<span id="statusDot" class="status-dot loading"></span>
<span id="statusText">Đang kết nối cơ sở dữ liệu quốc gia...</span>
</div>
<!-- Khu vực tra cứu chính -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Tra Cứu Thông Tin</h2>
</div>
<div class="card-body">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('new')">Tra cứu theo Đơn vị Mới</button>
<button class="tab-btn" onclick="switchTab('old')">Tra cứu theo Tên Cũ</button>
</div>
<!-- Panel 1: Tra cứu xuôi (Mới -> Cũ) -->
<div id="panelNew">
<div class="form-group">
<label for="newProvince">Tỉnh / Thành phố trực thuộc Trung ương</label>
<select id="newProvince" disabled>
<option value="">Đang tải dữ liệu...</option>
</select>
</div>
<div class="form-group">
<label for="newUnit">Đơn vị hành chính cấp xã (Phường/Xã/Thị trấn)</label>
<select id="newUnit" disabled>
<option value="">-- Vui lòng chọn Tỉnh/Thành phố trước --</option>
</select>
</div>
<div class="btn-container">
<button class="btn-primary" id="btnLookupNew">Thực hiện Tra cứu</button>
<button class="btn-secondary" id="btnResetNew">Làm mới</button>
</div>
<div id="outNew"></div>
</div>
<!-- Panel 2: Tra cứu ngược (Cũ -> Mới) -->
<div id="panelOld" style="display: none;">
<div class="form-group">
<label for="oldUnit">Tên đơn vị hành chính cũ (trước sáp nhập)</label>
<input type="text" id="oldUnit" placeholder="Nhập tên xã/phường cũ (ví dụ: Xã Đông Yên, Phường 2...)">
<div style="font-size: 0.85rem; color: #666; margin-top: 5px;">* Hệ thống hỗ trợ tìm kiếm có dấu và không dấu.</div>
</div>
<div class="btn-container">
<button class="btn-primary" id="btnLookupOld">Tìm kiếm</button>
<button class="btn-secondary" id="btnResetOld">Xóa trắng</button>
</div>
<div id="outOld"></div>
</div>
</div>
</div>
<!-- Thông tin thống kê -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Số Liệu Tổng Quan</h2>
</div>
<div class="card-body" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; text-align: center;">
<div>
<div style="font-size: 1.5rem; font-weight: 900; color: var(--gov-red);" id="stProvinces">-</div>
<div style="font-size: 0.85rem; color: #555;">Tỉnh/TP có biến động</div>
</div>
<div>
<div style="font-size: 1.5rem; font-weight: 900; color: var(--gov-red);" id="stNewUnits">-</div>
<div style="font-size: 0.85rem; color: #555;">Đơn vị hành chính mới</div>
</div>
<div>
<div style="font-size: 1.5rem; font-weight: 900; color: var(--gov-red);" id="stRows">-</div>
<div style="font-size: 0.85rem; color: #555;">Tổng số bản ghi</div>
</div>
</div>
</div>
</main>
<footer>
<p>HỆ THỐNG TRA CỨU HÀNH CHÍNH QUỐC GIA (PHIÊN BẢN THỬ NGHIỆM 1.0)</p>
<p>Mã nguồn mở | Hosting: Hugging Face Spaces</p>
<p>© Long Ngo 2025. Căn cứ Nghị quyết 202/2025/QH15 do Quốc hội ban hành.</p>
</footer>
<script>
(function() {
let DB = null;
// Cấu hình URL dữ liệu: Sử dụng đường dẫn tuyệt đối cho môi trường Space/Blob
// Đây là URL trực tiếp tới file raw trên Hugging Face Space của bạn
const DATA_URL = "https://huggingface.co/spaces/CVNSS/tracuusapnhap/resolve/main/tinhthanh.lookup.yaml";
const $ = (id) => document.getElementById(id);
// --- Hàm xử lý chuỗi (Slugify) ---
function slugify(s) {
return (s || "")
.trim().toLowerCase()
.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function escapeHtml(s) {
return (s ?? "").toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// --- Hàm hiển thị trạng thái ---
function updateStatus(state, msg) {
const dot = $("statusDot");
const text = $("statusText");
// Reset classes
dot.classList.remove("active", "error", "loading");
text.textContent = msg;
if (state === "loading") {
dot.classList.add("loading");
} else if (state === "success") {
dot.classList.add("active");
} else if (state === "error") {
dot.classList.add("error");
}
}
// --- Hàm Render Kết quả ---
function renderResultBox(container, htmlContent) {
container.innerHTML = htmlContent;
}
function renderEmpty(container, msg) {
container.innerHTML = `<div style="padding: 15px; color: #856404; background-color: #fff3cd; border: 1px solid #ffeeba; border-radius: 4px; margin-top: 15px;">⚠️ ${msg}</div>`;
}
// --- Chuyển Tab ---
window.switchTab = function(mode) {
const tabBtns = document.querySelectorAll(".tab-btn");
const panelNew = $("panelNew");
const panelOld = $("panelOld");
tabBtns.forEach(btn => btn.classList.remove("active"));
if (mode === 'new') {
tabBtns[0].classList.add("active");
panelNew.style.display = "block";
panelOld.style.display = "none";
} else {
tabBtns[1].classList.add("active");
panelNew.style.display = "none";
panelOld.style.display = "block";
}
}
// --- Logic Dữ liệu ---
function getProvinceNames() {
const arr = (DB?.data?.provinces || []).map(p => p.name).filter(Boolean);
return arr.sort((a,b) => a.localeCompare(b, "vi"));
}
function getUnitsForProvince(provinceName) {
const p = (DB?.data?.provinces || []).find(x => x.name === provinceName);
if (!p) return [];
const units = (p.units || []).map(u => u.name).filter(Boolean);
return units.sort((a,b) => a.localeCompare(b, "vi"));
}
function lookupByNewUnit(provinceName, unitName) {
const key = `${slugify(provinceName)}/${slugify(unitName)}`;
return DB?.index?.by_new_unit?.[key] || null;
}
function lookupByOldUnit(oldUnitName) {
const k = slugify(oldUnitName);
const hits = DB?.index?.by_old_unit_slug?.[k] || [];
return hits;
}
// --- Xử lý sự kiện ---
function wireControls() {
const pSel = $("newProvince");
const uSel = $("newUnit");
// Khi chọn Tỉnh
pSel.addEventListener("change", () => {
const pName = pSel.value;
uSel.innerHTML = '<option value="">-- Chọn Đơn vị hành chính --</option>';
$("outNew").innerHTML = "";
if (pName) {
const units = getUnitsForProvince(pName);
units.forEach(u => {
const opt = document.createElement("option");
opt.value = u;
opt.textContent = u;
uSel.appendChild(opt);
});
uSel.disabled = false;
} else {
uSel.disabled = true;
}
});
// Nút Tra cứu Mới -> Cũ
$("btnLookupNew").addEventListener("click", () => {
const pVal = pSel.value;
const uVal = uSel.value;
const out = $("outNew");
if (!pVal || !uVal) {
renderEmpty(out, "Vui lòng chọn đầy đủ Tỉnh và Đơn vị hành chính.");
return;
}
const hit = lookupByNewUnit(pVal, uVal);
if (!hit) {
renderEmpty(out, "Không tìm thấy dữ liệu trong hệ thống.");
return;
}
const oldList = (hit.merged_from_units || []).map(x => `<li>${escapeHtml(x)}</li>`).join("");
const provs = (hit.merged_from_provinces || []).join(", ");
renderResultBox(out, `
<div class="result-area">
<h3 class="result-title">KẾT QUẢ TRA CỨU</h3>
<div class="result-row">
<div class="result-label">Tỉnh / Thành phố:</div>
<div class="result-val highlight">${escapeHtml(hit.new_province)}</div>
</div>
<div class="result-row">
<div class="result-label">Đơn vị mới:</div>
<div class="result-val highlight">${escapeHtml(hit.new_unit)} <span style="font-weight:400; font-size:0.8em; color:#666">(${escapeHtml(hit.new_unit_type)})</span></div>
</div>
<div class="result-row">
<div class="result-label">Hình thành từ:</div>
<div class="result-val">
<ul style="margin: 0; padding-left: 20px; color: var(--gov-red-dark); font-weight: 500;">
${oldList || "<li>(Không có dữ liệu chi tiết)</li>"}
</ul>
</div>
</div>
<div class="result-row">
<div class="result-label">Ghi chú sáp nhập:</div>
<div class="result-val" style="font-style: italic; color: #555;">Sáp nhập từ tỉnh: ${escapeHtml(provs)}</div>
</div>
</div>
`);
});
// Nút Reset Mới -> Cũ
$("btnResetNew").addEventListener("click", () => {
pSel.value = "";
uSel.innerHTML = '<option value="">-- Vui lòng chọn Tỉnh/Thành phố trước --</option>';
uSel.disabled = true;
$("outNew").innerHTML = "";
});
// Nút Tra cứu Cũ -> Mới
$("btnLookupOld").addEventListener("click", () => {
const input = $("oldUnit");
const val = input.value.trim();
const out = $("outOld");
if (!val) {
renderEmpty(out, "Vui lòng nhập tên đơn vị cũ cần tìm.");
return;
}
const hits = lookupByOldUnit(val);
if (!hits || hits.length === 0) {
renderEmpty(out, `Không tìm thấy đơn vị cũ nào có tên "${escapeHtml(val)}". Hãy thử nhập không dấu hoặc kiểm tra chính tả.`);
return;
}
let html = `<div style="margin-top:15px; font-weight:bold; color:var(--gov-red-dark)">Tìm thấy ${hits.length} kết quả phù hợp:</div>`;
hits.forEach(h => {
html += `
<div class="result-area" style="margin-top: 10px;">
<div class="result-row">
<div class="result-label">Đơn vị cũ:</div>
<div class="result-val" style="color: #555;">${escapeHtml(h.old_unit || val)}</div>
</div>
<div style="border-top: 1px solid #ddd; margin: 8px 0;"></div>
<div class="result-row">
<div class="result-label">Nay thuộc về:</div>
<div class="result-val highlight">${escapeHtml(h.new_unit)}</div>
</div>
<div class="result-row">
<div class="result-label">Tỉnh / TP:</div>
<div class="result-val">${escapeHtml(h.new_province)}</div>
</div>
</div>`;
});
renderResultBox(out, html);
});
// Enter để tìm kiếm
$("oldUnit").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
$("btnLookupOld").click();
}
});
// Nút Reset Cũ -> Mới
$("btnResetOld").addEventListener("click", () => {
$("oldUnit").value = "";
$("outOld").innerHTML = "";
});
}
function initSelectors() {
const provinces = getProvinceNames();
const pSel = $("newProvince");
pSel.innerHTML = '<option value="">-- Chọn Tỉnh / Thành phố --</option>';
provinces.forEach(p => {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p;
pSel.appendChild(opt);
});
pSel.disabled = false;
}
// --- Hàm tải DB (Fix lỗi Fetch) ---
async function loadDB() {
try {
updateStatus("loading", "Đang tải dữ liệu từ máy chủ...");
// Xử lý logic Fetch thông minh
let response;
const isLocal = window.location.hostname === "localhost" || window.location.protocol === "file:";
// Nếu đang chạy local hoặc trên đúng domain space, thử tải relative trước
// Tuy nhiên, để an toàn tuyệt đối cho blob (preview) và production, ta ưu tiên URL tuyệt đối nếu URL hiện tại là blob
if (window.location.protocol === 'blob:') {
console.log("Phát hiện môi trường Blob/Preview. Sử dụng URL tuyệt đối.");
response = await fetch(DATA_URL);
} else {
// Thử tải file cùng cấp (relative) trước
try {
const localResp = await fetch("tinhthanh.lookup.yaml");
if (localResp.ok) {
response = localResp;
} else {
throw new Error("Local file not found");
}
} catch (err) {
console.warn("Không tải được file nội bộ, chuyển sang CDN/Remote...", err);
response = await fetch(DATA_URL);
}
}
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const txt = await response.text();
DB = jsyaml.load(txt);
// Cập nhật thống kê
const c = DB?.meta?.counts || {};
$("stRows").textContent = (c.rows || 0).toLocaleString('vi-VN');
$("stProvinces").textContent = (c.new_provinces || DB?.data?.provinces?.length || 0).toLocaleString('vi-VN');
$("stNewUnits").textContent = (c.new_units || 0).toLocaleString('vi-VN');
updateStatus("success", "Hệ thống sẵn sàng. Dữ liệu đã được cập nhật.");
initSelectors();
} catch (e) {
console.error(e);
updateStatus("error", "Lỗi tải dữ liệu! Vui lòng kiểm tra kết nối mạng.");
const pSel = $("newProvince");
pSel.innerHTML = "<option>Lỗi kết nối CSDL</option>";
}
}
// --- Khởi động ---
wireControls();
loadDB();
})();
</script>
</body>
</html>