mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-15 02:17:37 +00:00
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:
1 parent
f885aeb6cc
commit
97519b4b78
5 files changed
+501
-49
No files matched your search
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in new issue
Block a user