From f818003c3e46b10ba3c0aa11a41f9a859e4d64b6 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 14 Apr 2026 22:44:00 +0700 Subject: [PATCH] feat: deep-link, share, score legend, web font, grouped SQL presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.jsx owns query state, syncs to URL via ?q= (works for SBD or name); hydrates initial search from URL on DB ready - Loading panel now shows DB size (~47 MB) and one-time download note - Footer reports total student count from the live DB - Keyboard shortcut '/' focuses the search input when not already typing - StudentDetail: "Chia sẻ" button uses Web Share API or clipboard with a formatted multi-line summary including a deep-link URL - StudentDetail: inline tier legend (▽ ○ ◆ ★ ✦) with score ranges - ScoreTable cells now use background tint matching detail tiles - CustomQuery: presets grouped into 4 categories; auto-runs PRAGMA table_info(student) on first DB ready so the tab opens with the schema visible instead of a blank textarea - SearchForm accepts controlled value prop for URL hydration - Add Be Vietnam Pro web font for consistent Vietnamese diacritic rendering --- 2017/index.html | 6 ++ 2017/src/App.css | 95 +++++++++++++++--- 2017/src/App.jsx | 90 +++++++++++++---- 2017/src/components/custom-query.jsx | 132 ++++++++++++++++--------- 2017/src/components/search-form.jsx | 14 ++- 2017/src/components/student-detail.jsx | 73 ++++++++++++-- 6 files changed, 323 insertions(+), 87 deletions(-) diff --git a/2017/index.html b/2017/index.html index 1d2bdf8..6961ca3 100644 --- a/2017/index.html +++ b/2017/index.html @@ -4,6 +4,12 @@ + + + Tra cứu điểm thi THPT QG 2017 diff --git a/2017/src/App.css b/2017/src/App.css index c6ebadc..0264da5 100644 --- a/2017/src/App.css +++ b/2017/src/App.css @@ -67,11 +67,14 @@ body { margin: 0; background: var(--bg); color: var(--text); } border-radius: var(--radius-sm); } +body, .app { + font-family: "Be Vietnam Pro", system-ui, -apple-system, "Segoe UI", sans-serif; +} + .app { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; - font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--text); } @@ -129,18 +132,34 @@ header h1 { margin: 0 auto; } +.preset-groups { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin-bottom: 1rem; +} + +.preset-group { + display: grid; + grid-template-columns: 140px 1fr; + align-items: start; + gap: 0.75rem; +} + .preset-list { display: flex; flex-wrap: wrap; - gap: 0.5rem; - margin-bottom: 1rem; - align-items: center; + gap: 0.4rem; } .preset-label { - font-size: 0.85rem; + font-size: 0.8rem; + font-weight: 600; color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; white-space: nowrap; + padding-top: 0.3rem; } .preset-btn { @@ -348,6 +367,13 @@ header h1 { color: var(--text-muted); } +.loading-note { + margin: 0.5rem auto 0; + max-width: 480px; + font-size: 0.85rem; + color: var(--text-subtle); +} + .progress-bar { width: 300px; height: 8px; @@ -410,12 +436,12 @@ tbody tr:hover { background: var(--surface-alt); } font-weight: 500; } -/* Score tier color coding — used on both table cells and detail tiles */ -.score-cell.tier-poor { color: var(--score-poor-ink); } -.score-cell.tier-weak { color: var(--score-weak-ink); } -.score-cell.tier-fair { color: var(--score-fair-ink); } -.score-cell.tier-good { color: var(--score-good-ink); font-weight: 600; } -.score-cell.tier-excellent { color: var(--score-excellent-ink); font-weight: 700; } +/* Score tier color coding — mirror the detail-tile palette for scanability */ +.score-cell.tier-poor { color: var(--score-poor-ink); background: var(--score-poor-bg); } +.score-cell.tier-weak { color: var(--score-weak-ink); background: var(--score-weak-bg); } +.score-cell.tier-fair { color: var(--score-fair-ink); background: var(--score-fair-bg); } +.score-cell.tier-good { color: var(--score-good-ink); background: var(--score-good-bg); font-weight: 600; } +.score-cell.tier-excellent { color: var(--score-excellent-ink); background: var(--score-excellent-bg); font-weight: 700; } /* Messages */ .no-results, @@ -465,11 +491,19 @@ footer { } .detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; padding-bottom: 1rem; margin-bottom: 1.25rem; border-bottom: 1px solid var(--border); } +.detail-actions { + flex-shrink: 0; +} + .detail-identity h2 { margin: 0 0 0.5rem; font-size: 1.5rem; @@ -534,7 +568,8 @@ footer { color: var(--text); margin: 0 0 0.75rem; display: flex; - align-items: baseline; + align-items: center; + justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; } @@ -545,6 +580,39 @@ footer { color: var(--text-subtle); } +/* Tier legend — co-locates color/symbol/range/label */ +.tier-legend { + list-style: none; + margin: 0 0 0 auto; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + font-weight: 400; + font-size: 0.75rem; +} + +.tier-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-alt); +} + +.tier-legend-item.tier-poor { background: var(--score-poor-bg); color: var(--score-poor-ink); border-color: var(--score-poor-accent); } +.tier-legend-item.tier-weak { background: var(--score-weak-bg); color: var(--score-weak-ink); border-color: var(--score-weak-accent); } +.tier-legend-item.tier-fair { background: var(--score-fair-bg); color: var(--score-fair-ink); border-color: var(--score-fair-accent); } +.tier-legend-item.tier-good { background: var(--score-good-bg); color: var(--score-good-ink); border-color: var(--score-good-accent); } +.tier-legend-item.tier-excellent { background: var(--score-excellent-bg); color: var(--score-excellent-ink); border-color: var(--score-excellent-accent); } + +.tier-legend .tier-range { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + /* Subject tile grid */ .score-grid { list-style: none; @@ -676,7 +744,8 @@ footer { .search-input-row { flex-direction: column; } .search-input-row .primary-btn { width: 100%; } table { font-size: 0.8rem; } - .preset-list { flex-direction: column; align-items: flex-start; } + .preset-group { grid-template-columns: 1fr; gap: 0.35rem; } + .detail-header { flex-direction: column; } .detail-card { padding: 1.1rem; } .detail-identity h2 { font-size: 1.25rem; } .block-row { diff --git a/2017/src/App.jsx b/2017/src/App.jsx index dbce882..94646c7 100644 --- a/2017/src/App.jsx +++ b/2017/src/App.jsx @@ -8,6 +8,7 @@ import "./App.css"; const DB_URL = import.meta.env.BASE_URL + "thptqg2017.db.gz"; const MAX_RESULTS = 100; +const DB_SIZE_MB = 47; // Strip Vietnamese diacritics: "Nguyễn Bửu Lộc" → "nguyen buu loc" function toAscii(str) { @@ -19,58 +20,70 @@ function toAscii(str) { } 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; } +// Sync query to URL (?q=...) without adding history entries +function writeUrlQuery(q) { + const u = new URL(window.location.href); + if (q) u.searchParams.set("q", q); + else u.searchParams.delete("q"); + window.history.replaceState({}, "", u); +} + +function readUrlQuery() { + return new URL(window.location.href).searchParams.get("q") || ""; +} + 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"); + // Query is owned here so we can bidirectionally bind it to the URL + const [query, setQuery] = useState(() => readUrlQuery()); + const [totalCount, setTotalCount] = useState(null); const handleSearch = useCallback( - (query) => { + (q) => { if (!db) return; setSearchError(null); + setQuery(q); + writeUrlQuery(q); try { - const isExamId = /^\d+$/.test(query); + const isExamId = /^\d+$/.test(q); let stmt; if (isExamId) { stmt = db.prepare( "SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit", ); - stmt.bind({ $q: query, $limit: MAX_RESULTS }); - } else if (isAsciiOnly(query)) { - // ASCII input → match against folded column (handles "nguyen" = "Nguyễn") + stmt.bind({ $q: q, $limit: MAX_RESULTS }); + } else if (isAsciiOnly(q)) { stmt = db.prepare( "SELECT * FROM student WHERE ho_ten_ascii LIKE $q LIMIT $limit", ); - stmt.bind({ $q: `%${toAscii(query)}%`, $limit: MAX_RESULTS }); + stmt.bind({ $q: `%${toAscii(q)}%`, $limit: MAX_RESULTS }); } else { - // Vietnamese input → search both original and folded columns - const normalized = toAscii(query); + const normalized = toAscii(q); stmt = db.prepare( `SELECT * FROM student WHERE ho_ten LIKE $q OR ho_ten_ascii LIKE $qn LIMIT $limit`, ); stmt.bind({ - $q: `%${query}%`, + $q: `%${q}%`, $qn: `%${normalized}%`, $limit: MAX_RESULTS, }); } const rows = []; - while (stmt.step()) { - rows.push(stmt.getAsObject()); - } + while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); setResults(rows); } catch (err) { @@ -80,18 +93,51 @@ function App() { [db], ); - // Ctrl+Enter submits the SQL query form when in SQL tab + // Run initial URL query once DB is ready + useEffect(() => { + if (db && query) handleSearch(query); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [db]); + + // Fetch total count for footer once DB loads + useEffect(() => { + if (!db) return; + const stmt = db.prepare("SELECT COUNT(*) AS c FROM student"); + stmt.step(); + setTotalCount(stmt.getAsObject().c); + stmt.free(); + }, [db]); + + // Global keyboard shortcuts: + // Ctrl+Enter → submit SQL query (when SQL tab active) + // "/" → focus search box (when search tab active and not already typing) useEffect(() => { function handleKeyDown(e) { if (e.ctrlKey && e.key === "Enter" && activeTab === "sql") { const form = document.querySelector(".query-form"); if (form) form.requestSubmit(); + return; + } + if ( + e.key === "/" && + activeTab === "search" && + !(e.target instanceof HTMLInputElement) && + !(e.target instanceof HTMLTextAreaElement) + ) { + e.preventDefault(); + document.getElementById("search-input")?.focus(); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [activeTab]); + const handleClear = useCallback(() => { + setResults(null); + setQuery(""); + writeUrlQuery(""); + }, []); + return (
@@ -104,10 +150,17 @@ function App() {
{loading && (
-

Đang tải cơ sở dữ liệu... {progress > 0 ? `${progress}%` : ""}

+

+ Đang tải cơ sở dữ liệu ~{DB_SIZE_MB} MB + {progress > 0 ? ` · ${progress}%` : ""} +

+

+ Lần đầu có thể mất 10-30 giây. Sau đó trình duyệt sẽ lưu cache + và mở nhanh hơn. +

)} @@ -131,8 +184,9 @@ function App() { {activeTab === "search" && ( <> setResults(null)} + onClear={handleClear} disabled={loading || !!error} /> @@ -161,7 +215,9 @@ function App() {

- Nguồn: Sưu tầm từ trang báo thời đó · Dữ liệu chỉ mang tính tham khảo + Nguồn: baotintuc.vn + {totalCount !== null && ` · ${totalCount.toLocaleString("vi-VN")} thí sinh`} + {" · Dữ liệu chỉ mang tính tham khảo"}

diff --git a/2017/src/components/custom-query.jsx b/2017/src/components/custom-query.jsx index b07d56b..a669a6d 100644 --- a/2017/src/components/custom-query.jsx +++ b/2017/src/components/custom-query.jsx @@ -1,51 +1,64 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; const MAX_ROWS = 1000; -const PRESET_QUERIES = [ +const PRESET_GROUPS = [ { - label: "Top 10 điểm Toán cao nhất", - sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan + category: "Xếp hạng môn", + queries: [ + { + label: "Top 10 điểm Toán", + 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 + }, + { + label: "Top 10 KHTN", + 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 + }, + { + label: "Top 10 KHXH", + 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 ≥ 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: "Top 10 điểm Toán - Long An", - sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan + category: "Long An (SBD 49xxx)", + queries: [ + { + label: "Top 10 Toán - Long An", + sql: `SELECT so_bao_danh, ho_ten, ngay_sinh, toan FROM student WHERE so_bao_danh LIKE '49%' AND toan IS NOT NULL ORDER BY toan DESC LIMIT 10`, - }, - { - label: "Top 100 khối A - Long An", - sql: `SELECT so_bao_danh, ho_ten, toan, vat_ly, hoa_hoc, + }, + { + label: "Top 100 khối A - Long An", + sql: `SELECT so_bao_danh, ho_ten, toan, vat_ly, hoa_hoc, ROUND(toan + vat_ly + hoa_hoc, 2) AS tong_khoi_a FROM student WHERE so_bao_danh LIKE '49%' AND toan IS NOT NULL AND vat_ly IS NOT NULL AND hoa_hoc IS NOT NULL ORDER BY tong_khoi_a DESC LIMIT 100`, + }, + ], }, { - 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 + category: "Thống kê", + queries: [ + { + label: "Phân bố điểm Toán", + sql: `SELECT CASE WHEN toan < 1 THEN '0-1' WHEN toan < 2 THEN '1-2' @@ -62,30 +75,39 @@ ORDER BY toan DESC LIMIT 50`, 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 + }, + { + label: "Số TS 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 + }, + { + label: "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)`, + category: "Hệ thống", + queries: [ + { + label: "Schema bảng student", + sql: `PRAGMA table_info(student)`, + }, + ], }, ]; +const SCHEMA_PRESET = PRESET_GROUPS[PRESET_GROUPS.length - 1].queries[0]; + export function CustomQuery({ db, disabled }) { const [sql, setSql] = useState(""); const [columns, setColumns] = useState([]); @@ -158,19 +180,35 @@ export function CustomQuery({ db, disabled }) { executeQuery(presetSql); } + // Auto-run the schema preset the first time the tab is opened so the user + // sees the student columns instead of a blank textarea. + useEffect(() => { + if (db && columns.length === 0 && !sql) { + executeQuery(SCHEMA_PRESET.sql); + setSql(SCHEMA_PRESET.sql); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [db]); + return (
-
- Mẫu truy vấn: - {PRESET_QUERIES.map((p, i) => ( - +
+ {PRESET_GROUPS.map((group) => ( +
+ {group.category} +
+ {group.queries.map((p, i) => ( + + ))} +
+
))}
diff --git a/2017/src/components/search-form.jsx b/2017/src/components/search-form.jsx index 8a75269..c8e69a9 100644 --- a/2017/src/components/search-form.jsx +++ b/2017/src/components/search-form.jsx @@ -19,13 +19,18 @@ function detectMode(raw) { : { mode: "name-short", hint: `Cần ít nhất ${MIN_NAME_CHARS} ký tự` }; } -export function SearchForm({ onSearch, onClear, disabled }) { - const [query, setQuery] = useState(""); +export function SearchForm({ value = "", onSearch, onClear, disabled }) { + const [query, setQuery] = useState(value); const inputRef = useRef(null); const timerRef = useRef(null); const { mode, hint } = detectMode(query); const canSearch = mode === "sbd" || mode === "name"; + // Sync from external value (deep-link URL, clear button in parent) + useEffect(() => { + setQuery(value); + }, [value]); + // Debounced live search useEffect(() => { clearTimeout(timerRef.current); @@ -33,9 +38,12 @@ export function SearchForm({ onSearch, onClear, disabled }) { if (mode === "empty") onClear?.(); return; } + // Skip if the query already matches external value (prevents loop when + // value flows in from URL hydration) + if (query.trim() === value.trim() && query.trim() !== "") return; timerRef.current = setTimeout(() => onSearch(query.trim()), DEBOUNCE_MS); return () => clearTimeout(timerRef.current); - }, [query, canSearch, mode, onSearch, onClear]); + }, [query, canSearch, mode, onSearch, onClear, value]); function handleSubmit(e) { e.preventDefault(); diff --git a/2017/src/components/student-detail.jsx b/2017/src/components/student-detail.jsx index 43508a6..4c77359 100644 --- a/2017/src/components/student-detail.jsx +++ b/2017/src/components/student-detail.jsx @@ -29,18 +29,56 @@ function fmt(n) { return n === null || n === undefined ? "—" : Number(n).toFixed(2); } +// Visible tier legend — keeps symbol + range + label co-located so users +// don't have to hover tiles to decode colors. +const TIER_LEGEND = [ + { key: "poor", symbol: "▽", range: "< 5", label: "Chưa đạt" }, + { key: "weak", symbol: "○", range: "5-6.5", label: "Trung bình" }, + { key: "fair", symbol: "◆", range: "6.5-8", label: "Khá" }, + { key: "good", symbol: "★", range: "8-9", label: "Giỏi" }, + { key: "excellent", symbol: "✦", range: "≥ 9", label: "Xuất sắc" }, +]; + export function StudentDetail({ student }) { - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState(null); // null | 'sbd' | 'share' | 'url' const blocks = computeBlocks(student); const subjects = SUBJECT_ORDER .filter((k) => student[k] !== null && student[k] !== undefined) .map((k) => ({ key: k, label: SUBJECT_LABELS[k], score: student[k] })); + function flash(kind) { + setCopied(kind); + setTimeout(() => setCopied(null), 1500); + } + function copySbd() { - navigator.clipboard.writeText(student.so_bao_danh).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); + navigator.clipboard.writeText(student.so_bao_danh).then(() => flash("sbd")); + } + + // Formatted summary suitable for pasting into Zalo / Messenger: + // Nguyễn Văn A (SBD 49008235) + // Toán 8.50 · Ngữ văn 7.25 · ... + // Khối A: 22.75 + // Xem tại: https://...?q=49008235 + function shareSummary() { + const lines = []; + lines.push(`${student.ho_ten} (SBD ${student.so_bao_danh})`); + lines.push(subjects.map((s) => `${s.label} ${fmt(s.score)}`).join(" · ")); + if (blocks.length > 0) { + lines.push( + blocks.slice(0, 3).map((b) => `Khối ${b.code} ${b.total.toFixed(2)}`).join(" · "), + ); + } + lines.push( + `${window.location.origin}${window.location.pathname}?q=${student.so_bao_danh}`, + ); + const text = lines.join("\n"); + + if (navigator.share) { + navigator.share({ text }).catch(() => {}); + return; + } + navigator.clipboard.writeText(text).then(() => flash("share")); } return ( @@ -59,7 +97,7 @@ export function StudentDetail({ student }) { onClick={copySbd} aria-label="Sao chép số báo danh" > - {copied ? "✓ Đã chép" : "Chép"} + {copied === "sbd" ? "✓ Đã chép" : "Chép"}
@@ -71,10 +109,31 @@ export function StudentDetail({ student }) { )}
+
+ +
-

Điểm môn thi

+

+ Điểm môn thi +
    + {TIER_LEGEND.map((t) => ( +
  • + + {t.range} + {t.label} +
  • + ))} +
+

    {subjects.map((s) => { const tier = scoreTier(s.score);