mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-11 03:13:48 +00:00
feat: live search, student detail card, score tier color coding
- SearchForm: 300ms debounced live search, visible label, aria-live mode hint, inline clear (×) button, minimum-length guards (3 digits / 2 chars) - StudentDetail: rich single-result card with copy-SBD action, subject tiles with 5-tier color+icon coding, admission-block totals (A/A1/B/C/ D1-D4) computed only when the student has all 3 required subject scores - ScoreTable: per-cell tier color coding with tooltip - App.jsx: switches to detail card when exactly 1 result; onClear wired - App.css: semantic token system with light + dark (prefers-color-scheme) variants, :focus-visible ring, reduced-motion fallback, desktop-first breakpoints; removes hardcoded hex across components - admission-blocks.js: pure data + calculator, no UI coupling
This commit is contained in:
1 parent
d968459b9d
commit
c832810b78
6 files changed
+707
-98
No files matched your search
+434
-71
@@ -1,8 +1,78 @@
|
||||
/* Design tokens — semantic, paired for light + dark */
|
||||
:root {
|
||||
--bg: #f7f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f0f5;
|
||||
--border: #e2e2eb;
|
||||
--border-strong: #d0d0dc;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #5c6072;
|
||||
--text-subtle: #868a99;
|
||||
--primary: #3b4fe4;
|
||||
--primary-hover: #2d3ec9;
|
||||
--primary-ink: #ffffff;
|
||||
--focus-ring: 0 0 0 3px rgba(59, 79, 228, 0.35);
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 20, 40, 0.05);
|
||||
--shadow-md: 0 6px 20px rgba(15, 20, 40, 0.08);
|
||||
|
||||
/* Score tier colors (AA-contrast against tier-tinted surfaces) */
|
||||
--score-poor-bg: #fdecea; --score-poor-ink: #a51f1f; --score-poor-accent: #d33030;
|
||||
--score-weak-bg: #fff4e0; --score-weak-ink: #8a5500; --score-weak-accent: #e08a00;
|
||||
--score-fair-bg: #e8f0fe; --score-fair-ink: #1f4abf; --score-fair-accent: #3b73e8;
|
||||
--score-good-bg: #e6f6ec; --score-good-ink: #196a36; --score-good-accent: #2e9e55;
|
||||
--score-excellent-bg: #fff6d6; --score-excellent-ink: #7a5100; --score-excellent-accent: #d4a015;
|
||||
|
||||
--error-bg: #fdecea;
|
||||
--error-ink: #a51f1f;
|
||||
--warning-bg: #fff3e0;
|
||||
--warning-ink: #8a4a00;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1220;
|
||||
--surface: #171a2b;
|
||||
--surface-alt: #1e2236;
|
||||
--border: #2a2e44;
|
||||
--border-strong: #3a3f58;
|
||||
--text: #eaeaf0;
|
||||
--text-muted: #b0b4c7;
|
||||
--text-subtle: #8a8fa4;
|
||||
--primary: #6d82ff;
|
||||
--primary-hover: #879aff;
|
||||
--primary-ink: #0f1220;
|
||||
--focus-ring: 0 0 0 3px rgba(109, 130, 255, 0.45);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.55);
|
||||
|
||||
--score-poor-bg: #3a1717; --score-poor-ink: #ff9b9b; --score-poor-accent: #ff6b6b;
|
||||
--score-weak-bg: #3a2a0d; --score-weak-ink: #ffc980; --score-weak-accent: #ffb347;
|
||||
--score-fair-bg: #14244a; --score-fair-ink: #a9c0ff; --score-fair-accent: #7a96ff;
|
||||
--score-good-bg: #0f2e1b; --score-good-ink: #9ee3b5; --score-good-accent: #55c47a;
|
||||
--score-excellent-bg: #332a0a; --score-excellent-ink: #ffd76a; --score-excellent-accent: #f0bd3b;
|
||||
|
||||
--error-bg: #3a1717; --error-ink: #ff9b9b;
|
||||
--warning-bg: #3a2a0d; --warning-ink: #ffc980;
|
||||
}
|
||||
}
|
||||
|
||||
html { background: var(--bg); color: var(--text); }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); }
|
||||
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -12,12 +82,12 @@ header {
|
||||
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
color: #1a1a2e;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -27,7 +97,7 @@ header h1 {
|
||||
gap: 0;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 1.5rem;
|
||||
border-bottom: 2px solid #ddd;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -39,17 +109,17 @@ header h1 {
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #4361ee;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #4361ee;
|
||||
border-bottom-color: #4361ee;
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -69,23 +139,24 @@ header h1 {
|
||||
|
||||
.preset-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
background: #f0f0f5;
|
||||
border: 1px solid #ddd;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-btn:hover:not(:disabled) {
|
||||
background: #e8e8f0;
|
||||
border-color: #4361ee;
|
||||
background: var(--border);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.preset-btn:disabled {
|
||||
@@ -102,7 +173,9 @@ header h1 {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
|
||||
border: 2px solid #ddd;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
@@ -112,7 +185,7 @@ header h1 {
|
||||
}
|
||||
|
||||
.query-form textarea:focus {
|
||||
border-color: #4361ee;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.query-actions {
|
||||
@@ -125,8 +198,8 @@ header h1 {
|
||||
.query-actions button {
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
background: #4361ee;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
color: var(--primary-ink);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -134,7 +207,7 @@ header h1 {
|
||||
}
|
||||
|
||||
.query-actions button:hover:not(:disabled) {
|
||||
background: #3a56d4;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.query-actions button:disabled {
|
||||
@@ -144,62 +217,122 @@ header h1 {
|
||||
|
||||
.exec-time {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Search form */
|
||||
.search-form {
|
||||
max-width: 640px;
|
||||
margin: 0 auto 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.search-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.search-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
.search-input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-wrap input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.25rem 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-form input:focus {
|
||||
border-color: #4361ee;
|
||||
.search-input-wrap input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.search-input-wrap input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-form button {
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.search-form button[type="submit"] {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
background: #4361ee;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
background: var(--primary);
|
||||
color: var(--primary-ink);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.search-form button:hover:not(:disabled) {
|
||||
background: #3a56d4;
|
||||
.primary-btn:hover:not(:disabled) { background: var(--primary-hover); }
|
||||
.primary-btn:active:not(:disabled) { transform: scale(0.98); }
|
||||
.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.search-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
min-height: 1.2em;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.search-form button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
.search-hint.mode-sbd-short,
|
||||
.search-hint.mode-name-short {
|
||||
color: var(--score-weak-accent);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
color: #555;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 300px;
|
||||
height: 8px;
|
||||
background: #eee;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 4px;
|
||||
margin: 0.5rem auto;
|
||||
overflow: hidden;
|
||||
@@ -207,7 +340,7 @@ header h1 {
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #4361ee;
|
||||
background: var(--primary);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
@@ -219,7 +352,7 @@ header h1 {
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: #555;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -228,36 +361,43 @@ table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
thead th {
|
||||
background: #f0f0f5;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
padding: 0.6rem 0.5rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-bottom: 2px solid #ddd;
|
||||
border-bottom: 2px solid var(--border-strong);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
tbody tr:hover { background: var(--surface-alt); }
|
||||
|
||||
.name-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.name-cell { white-space: nowrap; }
|
||||
|
||||
.score-cell {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
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; }
|
||||
|
||||
/* Messages */
|
||||
.no-results,
|
||||
.error,
|
||||
@@ -269,18 +409,18 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.no-results {
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
background: #fdecea;
|
||||
color: var(--error-ink);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #e65100;
|
||||
background: #fff3e0;
|
||||
color: var(--warning-ink);
|
||||
background: var(--warning-bg);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -289,27 +429,250 @@ footer {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
color: #999;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- Student detail card ---- */
|
||||
.detail-card {
|
||||
max-width: 900px;
|
||||
margin: 1.5rem auto 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 1.75rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.detail-identity h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail-meta > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.detail-meta dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.detail-meta dd {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--primary);
|
||||
color: var(--primary-ink);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 0.75rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
/* Subject tile grid */
|
||||
.score-grid {
|
||||
list-style: none;
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.score-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-alt);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.score-tile .score-subject {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.score-tile .score-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.score-tile .score-tier {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.score-tile .score-symbol {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.score-tile.tier-poor { background: var(--score-poor-bg); border-color: var(--score-poor-accent); }
|
||||
.score-tile.tier-poor .score-value,
|
||||
.score-tile.tier-poor .score-tier,
|
||||
.score-tile.tier-poor .score-subject { color: var(--score-poor-ink); }
|
||||
|
||||
.score-tile.tier-weak { background: var(--score-weak-bg); border-color: var(--score-weak-accent); }
|
||||
.score-tile.tier-weak .score-value,
|
||||
.score-tile.tier-weak .score-tier,
|
||||
.score-tile.tier-weak .score-subject { color: var(--score-weak-ink); }
|
||||
|
||||
.score-tile.tier-fair { background: var(--score-fair-bg); border-color: var(--score-fair-accent); }
|
||||
.score-tile.tier-fair .score-value,
|
||||
.score-tile.tier-fair .score-tier,
|
||||
.score-tile.tier-fair .score-subject { color: var(--score-fair-ink); }
|
||||
|
||||
.score-tile.tier-good { background: var(--score-good-bg); border-color: var(--score-good-accent); }
|
||||
.score-tile.tier-good .score-value,
|
||||
.score-tile.tier-good .score-tier,
|
||||
.score-tile.tier-good .score-subject { color: var(--score-good-ink); }
|
||||
|
||||
.score-tile.tier-excellent { background: var(--score-excellent-bg); border-color: var(--score-excellent-accent); }
|
||||
.score-tile.tier-excellent .score-value,
|
||||
.score-tile.tier-excellent .score-tier,
|
||||
.score-tile.tier-excellent .score-subject { color: var(--score-excellent-ink); }
|
||||
|
||||
/* Admission block rows */
|
||||
.block-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.block-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.block-code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
padding: 0.15rem 0;
|
||||
background: var(--primary);
|
||||
color: var(--primary-ink);
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.block-parts {
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.block-total {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
min-width: 3.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
header h1 {
|
||||
font-size: 1.3rem;
|
||||
header h1 { font-size: 1.3rem; }
|
||||
.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; }
|
||||
.detail-card { padding: 1.1rem; }
|
||||
.detail-identity h2 { font-size: 1.25rem; }
|
||||
.block-row {
|
||||
grid-template-columns: 36px 1fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.8rem;
|
||||
.block-parts { display: none; }
|
||||
.score-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.preset-list {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -2,6 +2,7 @@ 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 { StudentDetail } from "./components/student-detail";
|
||||
import { CustomQuery } from "./components/custom-query";
|
||||
import "./App.css";
|
||||
|
||||
@@ -129,13 +130,21 @@ function App() {
|
||||
|
||||
{activeTab === "search" && (
|
||||
<>
|
||||
<SearchForm onSearch={handleSearch} disabled={loading || !!error} />
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
onClear={() => setResults(null)}
|
||||
disabled={loading || !!error}
|
||||
/>
|
||||
|
||||
{searchError && (
|
||||
<p className="error">Lỗi truy vấn: {searchError}</p>
|
||||
)}
|
||||
|
||||
<ScoreTable results={results} />
|
||||
{results && results.length === 1 ? (
|
||||
<StudentDetail student={results[0]} />
|
||||
) : (
|
||||
<ScoreTable results={results} />
|
||||
)}
|
||||
|
||||
{results && results.length >= MAX_RESULTS && (
|
||||
<p className="warning">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { scoreTier } from "../lib/admission-blocks";
|
||||
|
||||
const SUBJECT_COLUMNS = [
|
||||
{ key: "toan", label: "Toán" },
|
||||
{ key: "ngu_van", label: "Ngữ văn" },
|
||||
@@ -51,11 +53,19 @@ export function ScoreTable({ results }) {
|
||||
<td>{row.so_bao_danh}</td>
|
||||
<td className="name-cell">{row.ho_ten}</td>
|
||||
<td>{row.ngay_sinh || "—"}</td>
|
||||
{visibleColumns.map((col) => (
|
||||
<td key={col.key} className="score-cell">
|
||||
{formatScore(row[col.key])}
|
||||
</td>
|
||||
))}
|
||||
{visibleColumns.map((col) => {
|
||||
const tier = scoreTier(row[col.key]);
|
||||
const cls = tier ? `score-cell tier-${tier.key}` : "score-cell";
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cls}
|
||||
title={tier ? tier.label : undefined}
|
||||
>
|
||||
{formatScore(row[col.key])}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,31 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Search form - auto-detects search mode:
|
||||
* all digits → exact soBaoDanh match, otherwise → name LIKE search
|
||||
*/
|
||||
export function SearchForm({ onSearch, disabled }) {
|
||||
const DEBOUNCE_MS = 300;
|
||||
const MIN_SBD_DIGITS = 3;
|
||||
const MIN_NAME_CHARS = 2;
|
||||
|
||||
// Detect input mode for dynamic hint shown under the field
|
||||
function detectMode(raw) {
|
||||
const q = raw.trim();
|
||||
if (!q) return { mode: "empty", hint: "Gõ SBD (chữ số) hoặc họ tên để tìm" };
|
||||
if (/^\d+$/.test(q)) {
|
||||
return q.length >= MIN_SBD_DIGITS
|
||||
? { mode: "sbd", hint: `Tìm theo số báo danh · khớp chính xác` }
|
||||
: { mode: "sbd-short", hint: `Cần ít nhất ${MIN_SBD_DIGITS} chữ số` };
|
||||
}
|
||||
return q.length >= MIN_NAME_CHARS
|
||||
? { mode: "name", hint: "Tìm theo họ tên · không phân biệt dấu và hoa/thường" }
|
||||
: { mode: "name-short", hint: `Cần ít nhất ${MIN_NAME_CHARS} ký tự` };
|
||||
}
|
||||
|
||||
export function SearchForm({ onSearch, onClear, disabled }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const { mode, hint } = detectMode(query);
|
||||
const canSearch = mode === "sbd" || mode === "name";
|
||||
|
||||
// Debounced live search
|
||||
useEffect(() => {
|
||||
clearTimeout(timerRef.current);
|
||||
if (!canSearch) {
|
||||
if (mode === "empty") onClear?.();
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(() => onSearch(query.trim()), DEBOUNCE_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [query, canSearch, mode, onSearch, onClear]);
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) onSearch(trimmed);
|
||||
if (canSearch) {
|
||||
clearTimeout(timerRef.current);
|
||||
onSearch(query.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setQuery("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="search-form">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Nhập số báo danh hoặc họ tên..."
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={disabled || !query.trim()}>
|
||||
Tra cứu
|
||||
</button>
|
||||
<form onSubmit={handleSubmit} className="search-form" role="search">
|
||||
<label htmlFor="search-input" className="search-label">
|
||||
Tra cứu điểm
|
||||
</label>
|
||||
<div className="search-input-row">
|
||||
<div className={`search-input-wrap mode-${mode}`}>
|
||||
<input
|
||||
id="search-input"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
inputMode="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Ví dụ: 47006585 hoặc Nguyen Van An"
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby="search-hint"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-btn"
|
||||
onClick={handleClear}
|
||||
aria-label="Xoá tìm kiếm"
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary-btn"
|
||||
disabled={disabled || !canSearch}
|
||||
>
|
||||
Tra cứu
|
||||
</button>
|
||||
</div>
|
||||
<p id="search-hint" className={`search-hint mode-${mode}`} aria-live="polite">
|
||||
{hint}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import { computeBlocks, scoreTier } from "../lib/admission-blocks";
|
||||
|
||||
const SUBJECT_LABELS = {
|
||||
toan: "Toán",
|
||||
ngu_van: "Ngữ văn",
|
||||
vat_ly: "Vật lí",
|
||||
hoa_hoc: "Hóa học",
|
||||
sinh_hoc: "Sinh học",
|
||||
khtn: "KHTN",
|
||||
lich_su: "Lịch sử",
|
||||
dia_ly: "Địa lí",
|
||||
gdcd: "GDCD",
|
||||
khxh: "KHXH",
|
||||
tieng_anh: "Tiếng Anh",
|
||||
tieng_phap: "Tiếng Pháp",
|
||||
tieng_nga: "Tiếng Nga",
|
||||
tieng_trung: "Tiếng Trung",
|
||||
};
|
||||
|
||||
const SUBJECT_ORDER = [
|
||||
"toan", "ngu_van",
|
||||
"vat_ly", "hoa_hoc", "sinh_hoc", "khtn",
|
||||
"lich_su", "dia_ly", "gdcd", "khxh",
|
||||
"tieng_anh", "tieng_phap", "tieng_nga", "tieng_trung",
|
||||
];
|
||||
|
||||
function fmt(n) {
|
||||
return n === null || n === undefined ? "—" : Number(n).toFixed(2);
|
||||
}
|
||||
|
||||
export function StudentDetail({ student }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
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 copySbd() {
|
||||
navigator.clipboard.writeText(student.so_bao_danh).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="detail-card" aria-label="Chi tiết điểm thí sinh">
|
||||
<header className="detail-header">
|
||||
<div className="detail-identity">
|
||||
<h2>{student.ho_ten}</h2>
|
||||
<dl className="detail-meta">
|
||||
<div>
|
||||
<dt>SBD</dt>
|
||||
<dd>
|
||||
<span className="mono">{student.so_bao_danh}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="copy-btn"
|
||||
onClick={copySbd}
|
||||
aria-label="Sao chép số báo danh"
|
||||
>
|
||||
{copied ? "✓ Đã chép" : "Chép"}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
{student.ngay_sinh && (
|
||||
<div>
|
||||
<dt>Ngày sinh</dt>
|
||||
<dd className="mono">{student.ngay_sinh}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section aria-labelledby="subjects-heading">
|
||||
<h3 id="subjects-heading" className="section-title">Điểm môn thi</h3>
|
||||
<ul className="score-grid" role="list">
|
||||
{subjects.map((s) => {
|
||||
const tier = scoreTier(s.score);
|
||||
return (
|
||||
<li
|
||||
key={s.key}
|
||||
className={`score-tile tier-${tier.key}`}
|
||||
aria-label={`${s.label}: ${fmt(s.score)}, ${tier.label}`}
|
||||
>
|
||||
<span className="score-subject">{s.label}</span>
|
||||
<span className="score-value mono">{fmt(s.score)}</span>
|
||||
<span className="score-tier">
|
||||
<span className="score-symbol" aria-hidden="true">{tier.symbol}</span>
|
||||
{tier.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{blocks.length > 0 && (
|
||||
<section aria-labelledby="blocks-heading">
|
||||
<h3 id="blocks-heading" className="section-title">
|
||||
Tổng điểm khối thi
|
||||
<span className="section-hint">
|
||||
(Chỉ tính những khối thí sinh có đủ 3 môn)
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="block-list" role="list">
|
||||
{blocks.map((b) => (
|
||||
<li key={b.code} className="block-row">
|
||||
<span className="block-code">{b.code}</span>
|
||||
<span className="block-label">{b.label}</span>
|
||||
<span className="block-parts mono">
|
||||
{b.parts.map((p) => fmt(p.score)).join(" + ")}
|
||||
</span>
|
||||
<span className="block-total mono">{b.total.toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Vietnamese university admission-block ("khối thi") definitions for 2017.
|
||||
// Each block is the sum of 3 subjects; we only compute a block when ALL three
|
||||
// subject scores exist for the student.
|
||||
|
||||
export const ADMISSION_BLOCKS = [
|
||||
{ code: "A", subjects: ["toan", "vat_ly", "hoa_hoc"], label: "Toán + Lý + Hóa" },
|
||||
{ code: "A1", subjects: ["toan", "vat_ly", "tieng_anh"], label: "Toán + Lý + Anh" },
|
||||
{ code: "B", subjects: ["toan", "hoa_hoc", "sinh_hoc"], label: "Toán + Hóa + Sinh" },
|
||||
{ code: "C", subjects: ["ngu_van", "lich_su", "dia_ly"], label: "Văn + Sử + Địa" },
|
||||
{ code: "D1", subjects: ["toan", "ngu_van", "tieng_anh"], label: "Toán + Văn + Anh" },
|
||||
{ code: "D2", subjects: ["toan", "ngu_van", "tieng_nga"], label: "Toán + Văn + Nga" },
|
||||
{ code: "D3", subjects: ["toan", "ngu_van", "tieng_phap"], label: "Toán + Văn + Pháp" },
|
||||
{ code: "D4", subjects: ["toan", "ngu_van", "tieng_trung"], label: "Toán + Văn + Trung" },
|
||||
];
|
||||
|
||||
// Returns { code, label, total, parts:[{key,score}] } for every block where
|
||||
// the student has scores for all three subjects, sorted by total desc.
|
||||
export function computeBlocks(student) {
|
||||
const out = [];
|
||||
for (const b of ADMISSION_BLOCKS) {
|
||||
const parts = b.subjects.map((k) => ({ key: k, score: student[k] }));
|
||||
if (parts.some((p) => p.score === null || p.score === undefined)) continue;
|
||||
const total = parts.reduce((s, p) => s + p.score, 0);
|
||||
out.push({ code: b.code, label: b.label, total, parts });
|
||||
}
|
||||
return out.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
// Score tier: used for color + icon coding (meaning never conveyed by color alone)
|
||||
export function scoreTier(score) {
|
||||
if (score === null || score === undefined) return null;
|
||||
if (score < 5) return { key: "poor", symbol: "▽", label: "Chưa đạt" };
|
||||
if (score < 6.5) return { key: "weak", symbol: "○", label: "Trung bình" };
|
||||
if (score < 8) return { key: "fair", symbol: "◆", label: "Khá" };
|
||||
if (score < 9) return { key: "good", symbol: "★", label: "Giỏi" };
|
||||
return { key: "excellent", symbol: "✦", label: "Xuất sắc" };
|
||||
}
|
||||
Reference in new issue
Block a user