feat: diacritics-insensitive search and SQL query tab

- Add ho_ten_ascii column + index for accent-folded name search
- Parse Tiếng Pháp/Nga/Trung scores (recovers ~2k students' foreign-language data)
- Loosen score regex to accept integer values (e.g. KHTN: 4)
- App.jsx: 3-mode search (SBD / ASCII / Vietnamese) and Tra cứu/SQL tabs
- New custom-query component: 8 presets adapted to 2017 schema, read-only safety, auto-LIMIT
- ScoreTable: render new foreign-lang columns, hide columns null across all results
This commit is contained in:
tiennm99 committed 2026-04-14 20:44:43 +07:00
1 parent f885aeb6cc
commit 97519b4b78
5 files changed
+501 -49

No files matched your search

+59 -30
View File
@@ -9,21 +9,34 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RAW_DIR = path.join(__dirname, "..", "data", "raw");
const DB_PATH = path.join(__dirname, "..", "public", "thptqg2017.db");
// Score patterns matching the Java Converter.java regex
// Score patterns. Note: source data uses both "X.YZ" and "X" forms; allow optional decimal.
const NUM = "(\\d+(?:\\.\\d+)?)";
const SCORE_PATTERNS = {
toan: /Toán:\s*(\d*\.\d*)/,
ngu_van: /Ngữ văn:\s*(\d*\.\d*)/,
vat_ly: /Vật lí:\s*(\d*\.\d*)/,
hoa_hoc: /Hóa học:\s*(\d*\.\d*)/,
sinh_hoc: /Sinh học:\s*(\d*\.\d*)/,
khtn: /KHTN:\s*(\d*\.\d*)/,
lich_su: /Lịch sử:\s*(\d*\.\d*)/,
dia_ly: /Địa lí:\s*(\d*\.\d*)/,
gdcd: /GDCD:\s*(\d*\.\d*)/,
khxh: /KHXH:\s*(\d*\.\d*)/,
tieng_anh: /Tiếng Anh:\s*(\d*\.\d*)/,
toan: new RegExp("Toán:\\s*" + NUM),
ngu_van: new RegExp("Ngữ văn:\\s*" + NUM),
vat_ly: new RegExp("Vật lí:\\s*" + NUM),
hoa_hoc: new RegExp("Hóa học:\\s*" + NUM),
sinh_hoc: new RegExp("Sinh học:\\s*" + NUM),
khtn: new RegExp("KHTN:\\s*" + NUM),
lich_su: new RegExp("Lịch sử:\\s*" + NUM),
dia_ly: new RegExp("Địa lí:\\s*" + NUM),
gdcd: new RegExp("GDCD:\\s*" + NUM),
khxh: new RegExp("KHXH:\\s*" + NUM),
tieng_anh: new RegExp("Tiếng Anh:\\s*" + NUM),
tieng_phap: new RegExp("Tiếng Pháp:\\s*" + NUM),
tieng_nga: new RegExp("Tiếng Nga:\\s*" + NUM),
tieng_trung: new RegExp("Tiếng Trung:\\s*" + NUM),
};
// Strip Vietnamese diacritics for ASCII-insensitive search
function toAscii(str) {
return str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/gi, "d")
.toLowerCase();
}
// Collect all .xlsx files: raw/ first, then raw/(update)/ to overwrite
function collectExcelFiles() {
const files = [];
@@ -81,33 +94,43 @@ function main() {
// Create table
db.exec(`
CREATE TABLE student (
so_bao_danh TEXT PRIMARY KEY,
ho_ten TEXT NOT NULL,
ngay_sinh TEXT,
toan REAL,
ngu_van REAL,
vat_ly REAL,
hoa_hoc REAL,
sinh_hoc REAL,
khtn REAL,
lich_su REAL,
dia_ly REAL,
gdcd REAL,
khxh REAL,
tieng_anh REAL
so_bao_danh TEXT PRIMARY KEY,
ho_ten TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL,
ngay_sinh TEXT,
toan REAL,
ngu_van REAL,
vat_ly REAL,
hoa_hoc REAL,
sinh_hoc REAL,
khtn REAL,
lich_su REAL,
dia_ly REAL,
gdcd REAL,
khxh REAL,
tieng_anh REAL,
tieng_phap REAL,
tieng_nga REAL,
tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
`);
const insert = db.prepare(`
INSERT OR REPLACE INTO student
(so_bao_danh, ho_ten, ngay_sinh, toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn, lich_su, dia_ly, gdcd, khxh, tieng_anh)
(so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh,
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
lich_su, dia_ly, gdcd, khxh,
tieng_anh, tieng_phap, tieng_nga, tieng_trung)
VALUES
(@so_bao_danh, @ho_ten, @ngay_sinh, @toan, @ngu_van, @vat_ly, @hoa_hoc, @sinh_hoc, @khtn, @lich_su, @dia_ly, @gdcd, @khxh, @tieng_anh)
(@so_bao_danh, @ho_ten, @ho_ten_ascii, @ngay_sinh,
@toan, @ngu_van, @vat_ly, @hoa_hoc, @sinh_hoc, @khtn,
@lich_su, @dia_ly, @gdcd, @khxh,
@tieng_anh, @tieng_phap, @tieng_nga, @tieng_trung)
`);
const files = collectExcelFiles();
let totalRows = 0;
let errorCount = 0;
// Wrap all inserts in a single transaction for speed
@@ -141,6 +164,7 @@ function main() {
insert.run({
so_bao_danh: soBaoDanh,
ho_ten: hoTen,
ho_ten_ascii: toAscii(hoTen),
ngay_sinh: ngaySinh || null,
toan: scores.toan ?? null,
ngu_van: scores.ngu_van ?? null,
@@ -153,17 +177,22 @@ function main() {
gdcd: scores.gdcd ?? null,
khxh: scores.khxh ?? null,
tieng_anh: scores.tieng_anh ?? null,
tieng_phap: scores.tieng_phap ?? null,
tieng_nga: scores.tieng_nga ?? null,
tieng_trung: scores.tieng_trung ?? null,
});
fileRows++;
} catch (err) {
errorCount++;
if (errorCount <= 5) {
console.warn(` [warn] ${basename} row ${i}: ${err.message}`);
}
}
}
} catch (err) {
console.error(`Failed to read ${basename}: ${err.message}`);
}
totalRows += fileRows;
console.log(` ${basename}: ${fileRows} rows`);
}
});
+131
View File
@@ -21,6 +21,132 @@ header h1 {
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;
}
/* 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;
}
/* Search form */
.search-form {
display: flex;
@@ -181,4 +307,9 @@ footer {
table {
font-size: 0.8rem;
}
.preset-list {
flex-direction: column;
align-items: flex-start;
}
}
+86 -17
View File
@@ -1,16 +1,35 @@
import { useState, useCallback } from "react";
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 + "thptqg2017.db.gz";
const MAX_RESULTS = 100;
// Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc"
function toAscii(str) {
return str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/gi, "d")
.toLowerCase();
}
function isAsciiOnly(str) {
// True if no Vietnamese diacritics or non-ASCII chars present
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) return false;
}
return true;
}
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) => {
@@ -22,17 +41,29 @@ function App() {
let stmt;
if (isExamId) {
// Exact match on số báo danh
stmt = db.prepare(
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit"
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
);
stmt.bind({ $q: query, $limit: MAX_RESULTS });
} else {
// LIKE search on name (case-insensitive with Vietnamese)
} else if (isAsciiOnly(query)) {
// ASCII input → match against folded column (handles "nguyen" = "Nguyễn")
stmt = db.prepare(
"SELECT * FROM student WHERE ho_ten LIKE $q LIMIT $limit"
"SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit",
);
stmt.bind({ $q: `%${query}%`, $limit: MAX_RESULTS });
stmt.bind({ $q: `%${toAscii(query)}%`, $limit: MAX_RESULTS });
} else {
// Vietnamese input → search both original and folded columns
const normalized = toAscii(query);
stmt = db.prepare(
`SELECT * FROM student
WHERE ho_ten LIKE $q OR ho_ten_ascii LIKE $qn
LIMIT $limit`,
);
stmt.bind({
$q: `%${query}%`,
$qn: `%${normalized}%`,
$limit: MAX_RESULTS,
});
}
const rows = [];
@@ -40,20 +71,33 @@ function App() {
rows.push(stmt.getAsObject());
}
stmt.free();
setResults(rows);
} catch (err) {
setSearchError(err.message);
}
},
[db]
[db],
);
// Ctrl+Enter submits the SQL query form when 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 2017</h1>
<p className="subtitle">Dữ liệu 847.349 thí sinh toàn quốc</p>
<p className="subtitle">
Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh
</p>
</header>
<main>
@@ -68,16 +112,41 @@ function App() {
{error && <p className="error">Lỗi: {error}</p>}
<SearchForm onSearch={handleSearch} disabled={loading || !!error} />
<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>
{searchError && <p className="error">Lỗi truy vấn: {searchError}</p>}
{activeTab === "search" && (
<>
<SearchForm onSearch={handleSearch} disabled={loading || !!error} />
<ScoreTable results={results} />
{searchError && (
<p className="error">Lỗi truy vấn: {searchError}</p>
)}
{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>
<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>
+215
View File
@@ -0,0 +1,215 @@
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, ngay_sinh, toan
FROM student WHERE toan IS NOT NULL
ORDER BY toan DESC LIMIT 10`,
},
{
label: "Top 10 KHTN (TB Vật lí + Hóa + Sinh)",
sql: `SELECT so_bao_danh, ho_ten, vat_ly, hoa_hoc, sinh_hoc, khtn
FROM student WHERE khtn IS NOT NULL
ORDER BY khtn DESC LIMIT 10`,
},
{
label: "Top 10 KHXH (TB Sử + Địa + GDCD)",
sql: `SELECT so_bao_danh, ho_ten, lich_su, dia_ly, gdcd, khxh
FROM student WHERE khxh IS NOT NULL
ORDER BY khxh DESC LIMIT 10`,
},
{
label: "Thí sinh đạt 9+ điểm Toán",
sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, 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_nga IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nga,
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
FROM student`,
},
{
label: "Số thí sinh chọn KHTN vs KHXH",
sql: `SELECT
SUM(CASE WHEN khtn IS NOT NULL THEN 1 ELSE 0 END) AS chon_khtn,
SUM(CASE WHEN khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_khxh,
SUM(CASE WHEN khtn IS NOT NULL AND khxh IS NOT NULL THEN 1 ELSE 0 END) AS chon_ca_hai
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>
);
}
+10 -2
View File
@@ -10,6 +10,9 @@ const SUBJECT_COLUMNS = [
{ key: "gdcd", label: "GDCD" },
{ key: "khxh", label: "KHXH" },
{ key: "tieng_anh", label: "Tiếng Anh" },
{ key: "tieng_phap", label: "Tiếng Pháp" },
{ key: "tieng_nga", label: "Tiếng Nga" },
{ key: "tieng_trung", label: "Tiếng Trung" },
];
function formatScore(val) {
@@ -23,6 +26,11 @@ export function ScoreTable({ results }) {
return <p className="no-results">Không tìm thấy kết quả.</p>;
}
// Hide columns where every row in the result set is null (e.g. unused foreign langs)
const visibleColumns = SUBJECT_COLUMNS.filter((col) =>
results.some((row) => row[col.key] !== null && row[col.key] !== undefined),
);
return (
<div className="table-wrapper">
<p className="result-count">Tìm thấy {results.length} kết quả</p>
@@ -32,7 +40,7 @@ export function ScoreTable({ results }) {
<th>SBD</th>
<th>Họ tên</th>
<th>Ngày sinh</th>
{SUBJECT_COLUMNS.map((col) => (
{visibleColumns.map((col) => (
<th key={col.key}>{col.label}</th>
))}
</tr>
@@ -43,7 +51,7 @@ export function ScoreTable({ results }) {
<td>{row.so_bao_danh}</td>
<td className="name-cell">{row.ho_ten}</td>
<td>{row.ngay_sinh || "—"}</td>
{SUBJECT_COLUMNS.map((col) => (
{visibleColumns.map((col) => (
<td key={col.key} className="score-cell">
{formatScore(row[col.key])}
</td>