feat: add GitHub Pages app with custom SQL query support

- React + Vite + sql.js frontend with two-tab UI:
  - Quick search by exam ID (alphanumeric) or student name
  - Custom SQL query editor with 7 preset queries
- Build script handles all 5 Excel data formats (varied column
  orders, separate score columns, no-header, .xls/.xlsx)
- Database: 877,461 students with exam center, gender, 12 subjects
  (including 4 foreign languages: French, German, Japanese, Chinese)
- GitHub Actions CI/CD: build DB, gzip compress, deploy to Pages
- Safety: read-only queries only, auto LIMIT 1000, Ctrl+Enter shortcut
This commit is contained in:
tiennm99 committed 2026-04-14 20:05:38 +07:00
1 parent d81ed19a2c
commit 372f1e04fb
16 files changed
+4517

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build database
run: npm run build:db
- name: Compress database
run: gzip -k -9 public/thptqg2016.db
- name: Build site
run: npm run build
- name: Remove uncompressed db from dist
run: rm -f dist/thptqg2016.db
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+4
View File
@@ -137,3 +137,7 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Generated database files
public/*.db
public/*.db.gz
+31
View File
@@ -0,0 +1,31 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
export default [
{ ignores: ["dist"] },
{
files: ["**/*.{js,jsx}"],
languageOptions: {
ecmaVersion: 2024,
globals: globals.browser,
parserOptions: {
ecmaVersion: "latest",
ecmaFeatures: { jsx: true },
sourceType: "module",
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tra cứu điểm thi THPT QG 2016</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3235
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "thptqg2016",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Tra cứu điểm thi THPT QG 2016 - Hỗ trợ truy vấn SQL tùy chỉnh",
"scripts": {
"build:db": "node scripts/build-database.js",
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"repository": {
"type": "git",
"url": "git+https://github.com/tiennm99/thptqg2016.git"
},
"license": "ISC",
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sql.js": "^1.14.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"better-sqlite3": "^12.8.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.4",
"xlsx": "^0.18.5"
}
}
+257
View File
@@ -0,0 +1,257 @@
import XLSX from "xlsx";
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ASSETS_DIR = path.join(__dirname, "..", "assets");
const DB_PATH = path.join(__dirname, "..", "public", "thptqg2016.db");
// Score patterns for the DIEM_THI string format
const SCORE_PATTERNS = {
toan: /Toán:\s*([\d.]+)/,
ngu_van: /Ngữ văn:\s*([\d.]+)/,
vat_ly: /Vật lí:\s*([\d.]+)/,
hoa_hoc: /Hóa học:\s*([\d.]+)/,
sinh_hoc: /Sinh học:\s*([\d.]+)/,
lich_su: /Lịch sử:\s*([\d.]+)/,
dia_ly: /Địa lí:\s*([\d.]+)/,
tieng_anh: /Tiếng Anh:\s*([\d.]+)/,
tieng_phap: /Tiếng Pháp:\s*([\d.]+)/,
tieng_duc: /Tiếng Đức:\s*([\d.]+)/,
tieng_nhat: /Tiếng Nhật:\s*([\d.]+)/,
tieng_trung: /Tiếng Trung:\s*([\d.]+)/,
};
const ALL_SCORE_FIELDS = Object.keys(SCORE_PATTERNS);
// Parse score text "Toán: 3.75 Ngữ văn: 5.00 ..." into { toan: 3.75, ... }
function parseScoreString(diemThi) {
const scores = {};
for (const [field, pattern] of Object.entries(SCORE_PATTERNS)) {
const match = diemThi.match(pattern);
if (match) scores[field] = parseFloat(match[1]);
}
return scores;
}
// Detect header row by checking for known column names
const KNOWN_HEADERS = new Set([
"SOBAODANH", "SBD", "HO_TEN", "HOTEN", "HỌ TÊN",
"NGAY_SINH", "TEN_CUMTHI", "GIOI_TINH", "DIEM_THI", "STT",
"TOAN", "VAN", "LY", "HOA", "SINH ", "SU", "DIA",
]);
function isHeaderRow(row) {
if (!row || row.length < 2) return false;
const first = String(row[0] || "").trim().toUpperCase();
return KNOWN_HEADERS.has(first);
}
// Detect which format a file uses based on its header row
function detectFormat(headerRow) {
if (!headerRow) return null;
const cols = headerRow.map((c) => String(c || "").trim().toUpperCase());
// Format: SBD, HOTEN, TOAN, VAN, LY, HOA, SINH, SU, DIA, ...
if (cols[0] === "SBD" && cols[2] === "TOAN") return "separate-scores";
// Build a column index map for flexible column ordering
const map = {};
for (let i = 0; i < cols.length; i++) {
const c = cols[i];
if (c === "SOBAODANH" || c === "SBD") map.sbd = i;
else if (c === "HO_TEN" || c === "HOTEN" || c === "HỌ TÊN") map.ho_ten = i;
else if (c === "NGAY_SINH") map.ngay_sinh = i;
else if (c === "TEN_CUMTHI") map.ten_cum_thi = i;
else if (c === "GIOI_TINH") map.gioi_tinh = i;
else if (c === "DIEM_THI") map.diem_thi = i;
}
if (map.sbd !== undefined && map.diem_thi !== undefined) {
return { type: "mapped", map };
}
return null;
}
// Process a file with separate score columns (dhhanghai format)
function processSeparateScoresRow(row) {
const sbd = String(row[0] || "").trim();
const hoTen = String(row[1] || "").trim();
if (!sbd || !hoTen) return null;
return {
so_bao_danh: sbd,
ho_ten: hoTen,
ngay_sinh: null,
ten_cum_thi: null,
gioi_tinh: null,
toan: parseFloat(row[2]) || null,
ngu_van: parseFloat(row[3]) || null,
vat_ly: parseFloat(row[4]) || null,
hoa_hoc: parseFloat(row[5]) || null,
sinh_hoc: parseFloat(row[6]) || null,
lich_su: parseFloat(row[7]) || null,
dia_ly: parseFloat(row[8]) || null,
// row[9]=NGOAINGUTN, row[10]=NGOAINGUTL, row[11]=NGOAINGU (total)
tieng_anh: parseFloat(row[11]) || null,
tieng_phap: null,
tieng_duc: null,
tieng_nhat: null,
tieng_trung: null,
};
}
// Process a row using the column map
function processMappedRow(row, map) {
const sbd = String(row[map.sbd] || "").trim();
const hoTen = String(row[map.ho_ten] || "").trim();
if (!sbd || !hoTen) return null;
// Skip leaked header rows
const sbdUpper = sbd.toUpperCase();
if (KNOWN_HEADERS.has(sbdUpper) || KNOWN_HEADERS.has(hoTen.toUpperCase())) return null;
const ngaySinh = map.ngay_sinh !== undefined ? String(row[map.ngay_sinh] || "").trim() : null;
const tenCumThi = map.ten_cum_thi !== undefined ? String(row[map.ten_cum_thi] || "").trim() : null;
const rawGioiTinh = map.gioi_tinh !== undefined ? String(row[map.gioi_tinh] || "").trim() : null;
// Normalize gender: only accept "Nam" or "Nữ"
const gioiTinh = (rawGioiTinh === "Nam" || rawGioiTinh === "Nữ") ? rawGioiTinh : null;
const diemThi = map.diem_thi !== undefined ? String(row[map.diem_thi] || "") : "";
const scores = parseScoreString(diemThi);
return {
so_bao_danh: sbd,
ho_ten: hoTen,
ngay_sinh: ngaySinh || null,
ten_cum_thi: tenCumThi || null,
gioi_tinh: gioiTinh || null,
...Object.fromEntries(ALL_SCORE_FIELDS.map((f) => [f, scores[f] ?? null])),
};
}
// Standard 6-column format without header: SBD, HO_TEN, NGAY_SINH, TEN_CUMTHI, GIOI_TINH, DIEM_THI
const DEFAULT_MAP = {
sbd: 0, ho_ten: 1, ngay_sinh: 2, ten_cum_thi: 3, gioi_tinh: 4, diem_thi: 5,
};
function main() {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
if (fs.existsSync(DB_PATH)) fs.unlinkSync(DB_PATH);
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE student (
so_bao_danh TEXT PRIMARY KEY,
ho_ten TEXT NOT NULL,
ngay_sinh TEXT,
ten_cum_thi TEXT,
gioi_tinh TEXT,
toan REAL,
ngu_van REAL,
vat_ly REAL,
hoa_hoc REAL,
sinh_hoc REAL,
lich_su REAL,
dia_ly REAL,
tieng_anh REAL,
tieng_phap REAL,
tieng_duc REAL,
tieng_nhat REAL,
tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi);
`);
const insert = db.prepare(`
INSERT OR REPLACE INTO student
(so_bao_danh, ho_ten, ngay_sinh, ten_cum_thi, gioi_tinh,
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, lich_su, dia_ly,
tieng_anh, tieng_phap, tieng_duc, tieng_nhat, tieng_trung)
VALUES
(@so_bao_danh, @ho_ten, @ngay_sinh, @ten_cum_thi, @gioi_tinh,
@toan, @ngu_van, @vat_ly, @hoa_hoc, @sinh_hoc, @lich_su, @dia_ly,
@tieng_anh, @tieng_phap, @tieng_duc, @tieng_nhat, @tieng_trung)
`);
// Collect all Excel files (.xlsx and .xls)
const files = fs.readdirSync(ASSETS_DIR)
.filter((f) => f.endsWith(".xlsx") || f.endsWith(".xls"))
.map((f) => path.join(ASSETS_DIR, f));
let totalRows = 0;
let errorCount = 0;
const insertAll = db.transaction((files) => {
for (const file of files) {
const basename = path.basename(file);
let fileRows = 0;
try {
const wb = XLSX.readFile(file);
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(ws, { header: 1 });
if (rows.length === 0) continue;
let startRow = 0;
let format = null;
if (isHeaderRow(rows[0])) {
format = detectFormat(rows[0]);
startRow = 1;
}
for (let i = startRow; i < rows.length; i++) {
const row = rows[i];
if (!row || row.length < 2) continue;
try {
let record;
if (format === "separate-scores") {
record = processSeparateScoresRow(row);
} else if (format && format.type === "mapped") {
record = processMappedRow(row, format.map);
} else {
// No header or unrecognized: assume standard 6-column order
record = processMappedRow(row, DEFAULT_MAP);
}
if (!record) continue;
insert.run(record);
fileRows++;
} catch {
errorCount++;
}
}
} catch (err) {
console.error(`Failed to read ${basename}: ${err.message}`);
}
totalRows += fileRows;
console.log(` ${basename}: ${fileRows} rows`);
}
});
console.log(`Processing ${files.length} Excel files...\n`);
insertAll(files);
db.exec("VACUUM");
const count = db.prepare("SELECT COUNT(*) as cnt FROM student").get();
console.log(`\nDone! ${count.cnt} students in database.`);
console.log(`Errors skipped: ${errorCount}`);
console.log(`Output: ${DB_PATH}`);
const stat = fs.statSync(DB_PATH);
console.log(`Size: ${(stat.size / 1024 / 1024).toFixed(1)} MB`);
db.close();
}
main();
+323
View File
@@ -0,0 +1,323 @@
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 1.8rem;
color: #1a1a2e;
margin-bottom: 0.25rem;
}
.subtitle {
color: #666;
font-size: 0.95rem;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
max-width: 600px;
margin: 0 auto 1.5rem;
border-bottom: 2px solid #ddd;
}
.tab {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
color: #666;
transition: color 0.2s, border-color 0.2s;
}
.tab:hover {
color: #4361ee;
}
.tab.active {
color: #4361ee;
border-bottom-color: #4361ee;
font-weight: 600;
}
/* Search form */
.search-form {
display: flex;
gap: 0.5rem;
max-width: 600px;
margin: 0 auto 1.5rem;
}
.search-form input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
}
.search-form input:focus {
border-color: #4361ee;
}
.search-form button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background: #4361ee;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.search-form button:hover:not(:disabled) {
background: #3a56d4;
}
.search-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Custom query */
.custom-query {
max-width: 900px;
margin: 0 auto;
}
.preset-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
align-items: center;
}
.preset-label {
font-size: 0.85rem;
color: #666;
white-space: nowrap;
}
.preset-btn {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
background: #f0f0f5;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.preset-btn:hover:not(:disabled) {
background: #e8e8f0;
border-color: #4361ee;
}
.preset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.query-form {
margin-bottom: 1rem;
}
.query-form textarea {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
border: 2px solid #ddd;
border-radius: 8px;
outline: none;
resize: vertical;
min-height: 120px;
transition: border-color 0.2s;
tab-size: 2;
}
.query-form textarea:focus {
border-color: #4361ee;
}
.query-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
.query-actions button {
padding: 0.6rem 1.5rem;
font-size: 0.95rem;
background: #4361ee;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.query-actions button:hover:not(:disabled) {
background: #3a56d4;
}
.query-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exec-time {
font-size: 0.85rem;
color: #666;
}
/* Loading */
.loading {
text-align: center;
margin: 1rem 0;
color: #555;
}
.progress-bar {
width: 300px;
height: 8px;
background: #eee;
border-radius: 4px;
margin: 0.5rem auto;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #4361ee;
border-radius: 4px;
transition: width 0.3s;
}
/* Results table */
.table-wrapper {
overflow-x: auto;
margin-top: 1rem;
}
.result-count {
color: #555;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
thead th {
background: #f0f0f5;
padding: 0.6rem 0.5rem;
text-align: left;
white-space: nowrap;
position: sticky;
top: 0;
border-bottom: 2px solid #ddd;
}
tbody td {
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
tbody tr:hover {
background: #f8f9ff;
}
.name-cell {
white-space: nowrap;
}
.cumthi-cell {
font-size: 0.8rem;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.score-cell {
text-align: center;
font-variant-numeric: tabular-nums;
}
/* Messages */
.no-results,
.error,
.warning {
text-align: center;
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.no-results {
color: #666;
background: #f5f5f5;
}
.error {
color: #d32f2f;
background: #fdecea;
}
.warning {
color: #e65100;
background: #fff3e0;
font-size: 0.9rem;
}
/* Footer */
footer {
text-align: center;
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #eee;
color: #999;
font-size: 0.85rem;
}
/* Responsive */
@media (max-width: 600px) {
header h1 {
font-size: 1.3rem;
}
.search-form {
flex-direction: column;
}
table {
font-size: 0.8rem;
}
.preset-list {
flex-direction: column;
align-items: flex-start;
}
}
+142
View File
@@ -0,0 +1,142 @@
import { useState, useCallback, useEffect } from "react";
import { useSqlite } from "./hooks/use-sqlite";
import { SearchForm } from "./components/search-form";
import { ScoreTable } from "./components/score-table";
import { CustomQuery } from "./components/custom-query";
import "./App.css";
const DB_URL = import.meta.env.BASE_URL + "thptqg2016.db.gz";
const MAX_RESULTS = 100;
function App() {
const { db, loading, error, progress } = useSqlite(DB_URL);
const [results, setResults] = useState(null);
const [searchError, setSearchError] = useState(null);
const [activeTab, setActiveTab] = useState("search");
const handleSearch = useCallback(
(query) => {
if (!db) return;
setSearchError(null);
try {
const isExamId = /^[A-Z]{2,4}\d+$/i.test(query) || /^\d+$/.test(query);
let stmt;
if (isExamId) {
stmt = db.prepare(
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
);
stmt.bind({ $q: query.toUpperCase(), $limit: MAX_RESULTS });
} else {
stmt = db.prepare(
"SELECT * FROM student WHERE ho_ten LIKE $q LIMIT $limit",
);
stmt.bind({ $q: `%${query}%`, $limit: MAX_RESULTS });
}
const rows = [];
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
stmt.free();
setResults(rows);
} catch (err) {
setSearchError(err.message);
}
},
[db],
);
// Ctrl+Enter shortcut to execute query in SQL tab
useEffect(() => {
function handleKeyDown(e) {
if (e.ctrlKey && e.key === "Enter" && activeTab === "sql") {
const form = document.querySelector(".query-form");
if (form) form.requestSubmit();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [activeTab]);
return (
<div className="app">
<header>
<h1>Tra cứu điểm thi THPT Quốc gia 2016</h1>
<p className="subtitle">
Dữ liệu 877.461 thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh
</p>
</header>
<main>
{loading && (
<div className="loading">
<p>
Đang tải cơ sở dữ liệu...{" "}
{progress > 0 ? `${progress}%` : ""}
</p>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{error && <p className="error">Lỗi: {error}</p>}
<div className="tabs">
<button
className={`tab ${activeTab === "search" ? "active" : ""}`}
onClick={() => setActiveTab("search")}
>
Tra cứu
</button>
<button
className={`tab ${activeTab === "sql" ? "active" : ""}`}
onClick={() => setActiveTab("sql")}
>
Truy vấn SQL
</button>
</div>
{activeTab === "search" && (
<>
<SearchForm
onSearch={handleSearch}
disabled={loading || !!error}
/>
{searchError && (
<p className="error">Lỗi truy vấn: {searchError}</p>
)}
<ScoreTable results={results} />
{results && results.length >= MAX_RESULTS && (
<p className="warning">
Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ
thể hơn.
</p>
)}
</>
)}
{activeTab === "sql" && (
<CustomQuery db={db} disabled={loading || !!error} />
)}
</main>
<footer>
<p>
Nguồn: Sưu tầm từ trang báo thời đó · Dữ liệu chỉ mang tính tham
khảo
</p>
</footer>
</div>
);
}
export default App;
+218
View File
@@ -0,0 +1,218 @@
import { useState, useCallback } from "react";
const MAX_ROWS = 1000;
const PRESET_QUERIES = [
{
label: "Top 10 điểm Toán cao nhất",
sql: `SELECT so_bao_danh, ho_ten, ten_cum_thi, toan
FROM student WHERE toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Điểm trung bình theo cụm thi",
sql: `SELECT ten_cum_thi,
COUNT(*) AS so_luong,
ROUND(AVG(toan), 2) AS tb_toan,
ROUND(AVG(ngu_van), 2) AS tb_van,
ROUND(AVG(tieng_anh), 2) AS tb_anh
FROM student
GROUP BY ten_cum_thi
ORDER BY so_luong DESC
LIMIT 20`,
},
{
label: "Thống kê theo giới tính",
sql: `SELECT gioi_tinh,
COUNT(*) AS so_luong,
ROUND(AVG(toan), 2) AS tb_toan,
ROUND(AVG(ngu_van), 2) AS tb_van
FROM student
WHERE gioi_tinh IS NOT NULL
GROUP BY gioi_tinh`,
},
{
label: "Thí sinh đạt 9+ điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ten_cum_thi, gioi_tinh, toan
FROM student WHERE toan >= 9
ORDER BY toan DESC LIMIT 50`,
},
{
label: "Phân bố điểm Toán",
sql: `SELECT
CASE
WHEN toan < 1 THEN '0-1'
WHEN toan < 2 THEN '1-2'
WHEN toan < 3 THEN '2-3'
WHEN toan < 4 THEN '3-4'
WHEN toan < 5 THEN '4-5'
WHEN toan < 6 THEN '5-6'
WHEN toan < 7 THEN '6-7'
WHEN toan < 8 THEN '7-8'
WHEN toan < 9 THEN '8-9'
ELSE '9-10'
END AS khoang_diem,
COUNT(*) AS so_luong
FROM student WHERE toan IS NOT NULL
GROUP BY khoang_diem
ORDER BY khoang_diem`,
},
{
label: "Số thí sinh theo ngoại ngữ",
sql: `SELECT
SUM(CASE WHEN tieng_anh IS NOT NULL THEN 1 ELSE 0 END) AS tieng_anh,
SUM(CASE WHEN tieng_phap IS NOT NULL THEN 1 ELSE 0 END) AS tieng_phap,
SUM(CASE WHEN tieng_duc IS NOT NULL THEN 1 ELSE 0 END) AS tieng_duc,
SUM(CASE WHEN tieng_nhat IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nhat,
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
FROM student`,
},
{
label: "Schema bảng student",
sql: `PRAGMA table_info(student)`,
},
];
export function CustomQuery({ db, disabled }) {
const [sql, setSql] = useState("");
const [columns, setColumns] = useState([]);
const [rows, setRows] = useState([]);
const [queryError, setQueryError] = useState(null);
const [execTime, setExecTime] = useState(null);
const executeQuery = useCallback(
(queryStr) => {
if (!db) return;
setQueryError(null);
setColumns([]);
setRows([]);
setExecTime(null);
const trimmed = queryStr.trim();
if (!trimmed) return;
// Safety: only allow read-only statements
const upper = trimmed.toUpperCase();
const allowed = ["SELECT", "PRAGMA", "EXPLAIN", "WITH"];
if (!allowed.some((kw) => upper.startsWith(kw))) {
setQueryError(
"Chỉ hỗ trợ truy vấn đọc (SELECT, PRAGMA, EXPLAIN, WITH).",
);
return;
}
// Auto-add LIMIT if user forgot
let finalSql = trimmed;
if (
upper.startsWith("SELECT") &&
!upper.includes("LIMIT") &&
!upper.includes("PRAGMA")
) {
finalSql = `${trimmed.replace(/;$/, "")} LIMIT ${MAX_ROWS}`;
}
try {
const start = performance.now();
const stmt = db.prepare(finalSql);
const colNames = stmt.getColumnNames();
const resultRows = [];
let count = 0;
while (stmt.step() && count < MAX_ROWS) {
resultRows.push(stmt.get());
count++;
}
stmt.free();
const elapsed = performance.now() - start;
setColumns(colNames);
setRows(resultRows);
setExecTime(elapsed.toFixed(1));
} catch (err) {
setQueryError(err.message);
}
},
[db],
);
function handleSubmit(e) {
e.preventDefault();
executeQuery(sql);
}
function handlePreset(presetSql) {
setSql(presetSql);
executeQuery(presetSql);
}
return (
<div className="custom-query">
<div className="preset-list">
<span className="preset-label">Mẫu truy vấn:</span>
{PRESET_QUERIES.map((p, i) => (
<button
key={i}
className="preset-btn"
onClick={() => handlePreset(p.sql)}
disabled={disabled}
>
{p.label}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="query-form">
<textarea
value={sql}
onChange={(e) => setSql(e.target.value)}
placeholder={`Nhập truy vấn SQL...\nVí dụ: SELECT * FROM student WHERE toan >= 9 LIMIT 10`}
disabled={disabled}
rows={5}
spellCheck={false}
/>
<div className="query-actions">
<button type="submit" disabled={disabled || !sql.trim()}>
Thực thi (Ctrl+Enter)
</button>
{execTime !== null && (
<span className="exec-time">
{rows.length} kết quả · {execTime}ms
</span>
)}
</div>
</form>
{queryError && <p className="error">Lỗi: {queryError}</p>}
{columns.length > 0 && (
<div className="table-wrapper">
<table>
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>{col}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td key={ci} className="score-cell">
{cell === null ? "NULL" : String(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
{rows.length >= MAX_ROWS && (
<p className="warning">
Hiển thị tối đa {MAX_ROWS} kết quả. Thêm LIMIT để giới hạn.
</p>
)}
</div>
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
const SUBJECT_COLUMNS = [
{ key: "toan", label: "Toán" },
{ key: "ngu_van", label: "Ngữ văn" },
{ key: "vat_ly", label: "Vật lí" },
{ key: "hoa_hoc", label: "Hóa học" },
{ key: "sinh_hoc", label: "Sinh học" },
{ key: "lich_su", label: "Lịch sử" },
{ key: "dia_ly", label: "Địa lí" },
{ key: "tieng_anh", label: "T.Anh" },
{ key: "tieng_phap", label: "T.Pháp" },
{ key: "tieng_duc", label: "T.Đức" },
{ key: "tieng_nhat", label: "T.Nhật" },
{ key: "tieng_trung", label: "T.Trung" },
];
function formatScore(val) {
if (val === null || val === undefined) return "—";
return Number(val).toFixed(2);
}
export function ScoreTable({ results }) {
if (!results) return null;
if (results.length === 0) {
return <p className="no-results">Không tìm thấy kết quả.</p>;
}
return (
<div className="table-wrapper">
<p className="result-count">Tìm thấy {results.length} kết quả</p>
<table>
<thead>
<tr>
<th>SBD</th>
<th>Họ tên</th>
<th>Ngày sinh</th>
<th>Cụm thi</th>
<th>GT</th>
{SUBJECT_COLUMNS.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{results.map((row) => (
<tr key={row.so_bao_danh}>
<td>{row.so_bao_danh}</td>
<td className="name-cell">{row.ho_ten}</td>
<td>{row.ngay_sinh || "—"}</td>
<td className="cumthi-cell">{row.ten_cum_thi || "—"}</td>
<td>{row.gioi_tinh || "—"}</td>
{SUBJECT_COLUMNS.map((col) => (
<td key={col.key} className="score-cell">
{formatScore(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useState } from "react";
export function SearchForm({ onSearch, disabled }) {
const [query, setQuery] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = query.trim();
if (trimmed) onSearch(trimmed);
}
return (
<form onSubmit={handleSubmit} className="search-form">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Nhập số báo danh hoặc họ tên..."
disabled={disabled}
autoFocus
/>
<button type="submit" disabled={disabled || !query.trim()}>
Tra cứu
</button>
</form>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useState, useEffect, useRef } from "react";
import initSqlJs from "sql.js";
const SQL_WASM_URL = "https://sql.js.org/dist/sql-wasm.wasm";
/**
* Hook to load a SQLite database from a gzipped URL into sql.js.
* Returns { db, loading, error, progress }.
*/
export function useSqlite(dbUrl) {
const [db, setDb] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [progress, setProgress] = useState(0);
const dbRef = useRef(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const SQL = await initSqlJs({ locateFile: () => SQL_WASM_URL });
const response = await fetch(dbUrl);
if (!response.ok)
throw new Error(`Failed to fetch database: ${response.status}`);
const contentLength = +response.headers.get("Content-Length") || 0;
const reader = response.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
if (contentLength > 0) {
setProgress(Math.round((received / contentLength) * 100));
}
}
if (cancelled) return;
const blob = new Blob(chunks);
let arrayBuffer;
if (dbUrl.endsWith(".gz")) {
const ds = new DecompressionStream("gzip");
const decompressed = blob.stream().pipeThrough(ds);
const decompressedBlob = await new Response(decompressed).blob();
arrayBuffer = await decompressedBlob.arrayBuffer();
} else {
arrayBuffer = await blob.arrayBuffer();
}
if (cancelled) return;
const database = new SQL.Database(new Uint8Array(arrayBuffer));
dbRef.current = database;
setDb(database);
setLoading(false);
} catch (err) {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
}
}
load();
return () => {
cancelled = true;
if (dbRef.current) {
dbRef.current.close();
dbRef.current = null;
}
};
}, [dbUrl]);
return { db, loading, error, progress };
}
+13
View File
@@ -0,0 +1,13 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
background: #fafafa;
color: #333;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/thptqg2016/",
});