diff --git a/2017/scripts/build-database.js b/2017/scripts/build-database.js index 767cbf0..bb37ddb 100644 --- a/2017/scripts/build-database.js +++ b/2017/scripts/build-database.js @@ -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`); } }); diff --git a/2017/src/App.css b/2017/src/App.css index aac6ded..33598df 100644 --- a/2017/src/App.css +++ b/2017/src/App.css @@ -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; + } } diff --git a/2017/src/App.jsx b/2017/src/App.jsx index 27d6873..855e90d 100644 --- a/2017/src/App.jsx +++ b/2017/src/App.jsx @@ -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 (

Tra cứu điểm thi THPT Quốc gia 2017

-

Dữ liệu 847.349 thí sinh toàn quốc

+

+ Dữ liệu thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh +

@@ -68,16 +112,41 @@ function App() { {error &&

Lỗi: {error}

} - +
+ + +
- {searchError &&

Lỗi truy vấn: {searchError}

} + {activeTab === "search" && ( + <> + - + {searchError && ( +

Lỗi truy vấn: {searchError}

+ )} - {results && results.length >= MAX_RESULTS && ( -

- Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn. -

+ + + {results && results.length >= MAX_RESULTS && ( +

+ Hiển thị tối đa {MAX_RESULTS} kết quả. Vui lòng tìm kiếm cụ thể hơn. +

+ )} + + )} + + {activeTab === "sql" && ( + )}
diff --git a/2017/src/components/custom-query.jsx b/2017/src/components/custom-query.jsx new file mode 100644 index 0000000..081bd08 --- /dev/null +++ b/2017/src/components/custom-query.jsx @@ -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 ( +
+
+ Mẫu truy vấn: + {PRESET_QUERIES.map((p, i) => ( + + ))} +
+ +
+