mirror of
https://github.com/tiennm99/thptqg2017.git
synced 2026-10-03 09:13:53 +00:00
feat: deep-link, share, score legend, web font, grouped SQL presets
- 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
This commit is contained in:
1 parent
f1169d3d28
commit
95be7e479d
6 files changed
+323
-87
No files matched your search
@@ -4,6 +4,12 @@
|
||||
<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" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&display=swap"
|
||||
/>
|
||||
<title>Tra cứu điểm thi THPT QG 2017</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+82
-13
@@ -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 {
|
||||
|
||||
+73
-17
@@ -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 (
|
||||
<div className="app">
|
||||
<header>
|
||||
@@ -104,10 +150,17 @@ function App() {
|
||||
<main>
|
||||
{loading && (
|
||||
<div className="loading">
|
||||
<p>Đang tải cơ sở dữ liệu... {progress > 0 ? `${progress}%` : ""}</p>
|
||||
<p>
|
||||
Đang tải cơ sở dữ liệu ~{DB_SIZE_MB} MB
|
||||
{progress > 0 ? ` · ${progress}%` : ""}
|
||||
</p>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="loading-note">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -131,8 +184,9 @@ function App() {
|
||||
{activeTab === "search" && (
|
||||
<>
|
||||
<SearchForm
|
||||
value={query}
|
||||
onSearch={handleSearch}
|
||||
onClear={() => setResults(null)}
|
||||
onClear={handleClear}
|
||||
disabled={loading || !!error}
|
||||
/>
|
||||
|
||||
@@ -161,7 +215,9 @@ function App() {
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
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"}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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 className="preset-groups">
|
||||
{PRESET_GROUPS.map((group) => (
|
||||
<div key={group.category} className="preset-group">
|
||||
<span className="preset-label">{group.category}</span>
|
||||
<div className="preset-list">
|
||||
{group.queries.map((p, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="preset-btn"
|
||||
onClick={() => handlePreset(p.sql)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -71,10 +109,31 @@ export function StudentDetail({ student }) {
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary-btn"
|
||||
onClick={shareSummary}
|
||||
aria-label="Chia sẻ bảng điểm"
|
||||
>
|
||||
{copied === "share" ? "✓ Đã chép" : "Chia sẻ"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="subjects-heading">
|
||||
<h3 id="subjects-heading" className="section-title">Điểm môn thi</h3>
|
||||
<h3 id="subjects-heading" className="section-title">
|
||||
Điểm môn thi
|
||||
<ul className="tier-legend" role="list" aria-label="Chú thích mức điểm">
|
||||
{TIER_LEGEND.map((t) => (
|
||||
<li key={t.key} className={`tier-legend-item tier-${t.key}`}>
|
||||
<span aria-hidden="true">{t.symbol}</span>
|
||||
<span className="tier-range">{t.range}</span>
|
||||
<span className="tier-name">{t.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</h3>
|
||||
<ul className="score-grid" role="list">
|
||||
{subjects.map((s) => {
|
||||
const tier = scoreTier(s.score);
|
||||
|
||||
Reference in new issue
Block a user