mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-14 08:18:39 +00:00
feat: add GitHub Pages app with custom SQL query support
- React + Vite + sql.js frontend with two-tab UI: - Quick search by exam ID (alphanumeric) or student name - Custom SQL query editor with 7 preset queries - Build script handles all 5 Excel data formats (varied column orders, separate score columns, no-header, .xls/.xlsx) - Database: 877,461 students with exam center, gender, 12 subjects (including 4 foreign languages: French, German, Japanese, Chinese) - GitHub Actions CI/CD: build DB, gzip compress, deploy to Pages - Safety: read-only queries only, auto LIMIT 1000, Ctrl+Enter shortcut
This commit is contained in:
1 parent
d81ed19a2c
commit
372f1e04fb
16 files changed
+4517
No files matched your search
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build database
|
||||
run: npm run build:db
|
||||
|
||||
- name: Compress database
|
||||
run: gzip -k -9 public/thptqg2016.db
|
||||
|
||||
- name: Build site
|
||||
run: npm run build
|
||||
|
||||
- name: Remove uncompressed db from dist
|
||||
run: rm -f dist/thptqg2016.db
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -137,3 +137,7 @@ dist
|
||||
# Vite logs files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Generated database files
|
||||
public/*.db
|
||||
public/*.db.gz
|
||||
@@ -0,0 +1,31 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default [
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2024,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Tra cứu điểm thi THPT QG 2016</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3235
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "thptqg2016",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Tra cứu điểm thi THPT QG 2016 - Hỗ trợ truy vấn SQL tùy chỉnh",
|
||||
"scripts": {
|
||||
"build:db": "node scripts/build-database.js",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/tiennm99/thptqg2016.git"
|
||||
},
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"vite": "^8.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import XLSX from "xlsx";
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ASSETS_DIR = path.join(__dirname, "..", "assets");
|
||||
const DB_PATH = path.join(__dirname, "..", "public", "thptqg2016.db");
|
||||
|
||||
// Score patterns for the DIEM_THI string format
|
||||
const SCORE_PATTERNS = {
|
||||
toan: /Toán:\s*([\d.]+)/,
|
||||
ngu_van: /Ngữ văn:\s*([\d.]+)/,
|
||||
vat_ly: /Vật lí:\s*([\d.]+)/,
|
||||
hoa_hoc: /Hóa học:\s*([\d.]+)/,
|
||||
sinh_hoc: /Sinh học:\s*([\d.]+)/,
|
||||
lich_su: /Lịch sử:\s*([\d.]+)/,
|
||||
dia_ly: /Địa lí:\s*([\d.]+)/,
|
||||
tieng_anh: /Tiếng Anh:\s*([\d.]+)/,
|
||||
tieng_phap: /Tiếng Pháp:\s*([\d.]+)/,
|
||||
tieng_duc: /Tiếng Đức:\s*([\d.]+)/,
|
||||
tieng_nhat: /Tiếng Nhật:\s*([\d.]+)/,
|
||||
tieng_trung: /Tiếng Trung:\s*([\d.]+)/,
|
||||
};
|
||||
|
||||
const ALL_SCORE_FIELDS = Object.keys(SCORE_PATTERNS);
|
||||
|
||||
// Parse score text "Toán: 3.75 Ngữ văn: 5.00 ..." into { toan: 3.75, ... }
|
||||
function parseScoreString(diemThi) {
|
||||
const scores = {};
|
||||
for (const [field, pattern] of Object.entries(SCORE_PATTERNS)) {
|
||||
const match = diemThi.match(pattern);
|
||||
if (match) scores[field] = parseFloat(match[1]);
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
// Detect header row by checking for known column names
|
||||
const KNOWN_HEADERS = new Set([
|
||||
"SOBAODANH", "SBD", "HO_TEN", "HOTEN", "HỌ TÊN",
|
||||
"NGAY_SINH", "TEN_CUMTHI", "GIOI_TINH", "DIEM_THI", "STT",
|
||||
"TOAN", "VAN", "LY", "HOA", "SINH ", "SU", "DIA",
|
||||
]);
|
||||
|
||||
function isHeaderRow(row) {
|
||||
if (!row || row.length < 2) return false;
|
||||
const first = String(row[0] || "").trim().toUpperCase();
|
||||
return KNOWN_HEADERS.has(first);
|
||||
}
|
||||
|
||||
// Detect which format a file uses based on its header row
|
||||
function detectFormat(headerRow) {
|
||||
if (!headerRow) return null;
|
||||
const cols = headerRow.map((c) => String(c || "").trim().toUpperCase());
|
||||
|
||||
// Format: SBD, HOTEN, TOAN, VAN, LY, HOA, SINH, SU, DIA, ...
|
||||
if (cols[0] === "SBD" && cols[2] === "TOAN") return "separate-scores";
|
||||
|
||||
// Build a column index map for flexible column ordering
|
||||
const map = {};
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const c = cols[i];
|
||||
if (c === "SOBAODANH" || c === "SBD") map.sbd = i;
|
||||
else if (c === "HO_TEN" || c === "HOTEN" || c === "HỌ TÊN") map.ho_ten = i;
|
||||
else if (c === "NGAY_SINH") map.ngay_sinh = i;
|
||||
else if (c === "TEN_CUMTHI") map.ten_cum_thi = i;
|
||||
else if (c === "GIOI_TINH") map.gioi_tinh = i;
|
||||
else if (c === "DIEM_THI") map.diem_thi = i;
|
||||
}
|
||||
|
||||
if (map.sbd !== undefined && map.diem_thi !== undefined) {
|
||||
return { type: "mapped", map };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process a file with separate score columns (dhhanghai format)
|
||||
function processSeparateScoresRow(row) {
|
||||
const sbd = String(row[0] || "").trim();
|
||||
const hoTen = String(row[1] || "").trim();
|
||||
if (!sbd || !hoTen) return null;
|
||||
|
||||
return {
|
||||
so_bao_danh: sbd,
|
||||
ho_ten: hoTen,
|
||||
ngay_sinh: null,
|
||||
ten_cum_thi: null,
|
||||
gioi_tinh: null,
|
||||
toan: parseFloat(row[2]) || null,
|
||||
ngu_van: parseFloat(row[3]) || null,
|
||||
vat_ly: parseFloat(row[4]) || null,
|
||||
hoa_hoc: parseFloat(row[5]) || null,
|
||||
sinh_hoc: parseFloat(row[6]) || null,
|
||||
lich_su: parseFloat(row[7]) || null,
|
||||
dia_ly: parseFloat(row[8]) || null,
|
||||
// row[9]=NGOAINGUTN, row[10]=NGOAINGUTL, row[11]=NGOAINGU (total)
|
||||
tieng_anh: parseFloat(row[11]) || null,
|
||||
tieng_phap: null,
|
||||
tieng_duc: null,
|
||||
tieng_nhat: null,
|
||||
tieng_trung: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Process a row using the column map
|
||||
function processMappedRow(row, map) {
|
||||
const sbd = String(row[map.sbd] || "").trim();
|
||||
const hoTen = String(row[map.ho_ten] || "").trim();
|
||||
if (!sbd || !hoTen) return null;
|
||||
|
||||
// Skip leaked header rows
|
||||
const sbdUpper = sbd.toUpperCase();
|
||||
if (KNOWN_HEADERS.has(sbdUpper) || KNOWN_HEADERS.has(hoTen.toUpperCase())) return null;
|
||||
|
||||
const ngaySinh = map.ngay_sinh !== undefined ? String(row[map.ngay_sinh] || "").trim() : null;
|
||||
const tenCumThi = map.ten_cum_thi !== undefined ? String(row[map.ten_cum_thi] || "").trim() : null;
|
||||
const rawGioiTinh = map.gioi_tinh !== undefined ? String(row[map.gioi_tinh] || "").trim() : null;
|
||||
// Normalize gender: only accept "Nam" or "Nữ"
|
||||
const gioiTinh = (rawGioiTinh === "Nam" || rawGioiTinh === "Nữ") ? rawGioiTinh : null;
|
||||
const diemThi = map.diem_thi !== undefined ? String(row[map.diem_thi] || "") : "";
|
||||
|
||||
const scores = parseScoreString(diemThi);
|
||||
|
||||
return {
|
||||
so_bao_danh: sbd,
|
||||
ho_ten: hoTen,
|
||||
ngay_sinh: ngaySinh || null,
|
||||
ten_cum_thi: tenCumThi || null,
|
||||
gioi_tinh: gioiTinh || null,
|
||||
...Object.fromEntries(ALL_SCORE_FIELDS.map((f) => [f, scores[f] ?? null])),
|
||||
};
|
||||
}
|
||||
|
||||
// Standard 6-column format without header: SBD, HO_TEN, NGAY_SINH, TEN_CUMTHI, GIOI_TINH, DIEM_THI
|
||||
const DEFAULT_MAP = {
|
||||
sbd: 0, ho_ten: 1, ngay_sinh: 2, ten_cum_thi: 3, gioi_tinh: 4, diem_thi: 5,
|
||||
};
|
||||
|
||||
function main() {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
if (fs.existsSync(DB_PATH)) fs.unlinkSync(DB_PATH);
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE student (
|
||||
so_bao_danh TEXT PRIMARY KEY,
|
||||
ho_ten TEXT NOT NULL,
|
||||
ngay_sinh TEXT,
|
||||
ten_cum_thi TEXT,
|
||||
gioi_tinh TEXT,
|
||||
toan REAL,
|
||||
ngu_van REAL,
|
||||
vat_ly REAL,
|
||||
hoa_hoc REAL,
|
||||
sinh_hoc REAL,
|
||||
lich_su REAL,
|
||||
dia_ly REAL,
|
||||
tieng_anh REAL,
|
||||
tieng_phap REAL,
|
||||
tieng_duc REAL,
|
||||
tieng_nhat REAL,
|
||||
tieng_trung REAL
|
||||
);
|
||||
CREATE INDEX idx_ho_ten ON student(ho_ten);
|
||||
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi);
|
||||
`);
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT OR REPLACE INTO student
|
||||
(so_bao_danh, ho_ten, ngay_sinh, ten_cum_thi, gioi_tinh,
|
||||
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, lich_su, dia_ly,
|
||||
tieng_anh, tieng_phap, tieng_duc, tieng_nhat, tieng_trung)
|
||||
VALUES
|
||||
(@so_bao_danh, @ho_ten, @ngay_sinh, @ten_cum_thi, @gioi_tinh,
|
||||
@toan, @ngu_van, @vat_ly, @hoa_hoc, @sinh_hoc, @lich_su, @dia_ly,
|
||||
@tieng_anh, @tieng_phap, @tieng_duc, @tieng_nhat, @tieng_trung)
|
||||
`);
|
||||
|
||||
// Collect all Excel files (.xlsx and .xls)
|
||||
const files = fs.readdirSync(ASSETS_DIR)
|
||||
.filter((f) => f.endsWith(".xlsx") || f.endsWith(".xls"))
|
||||
.map((f) => path.join(ASSETS_DIR, f));
|
||||
|
||||
let totalRows = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
const insertAll = db.transaction((files) => {
|
||||
for (const file of files) {
|
||||
const basename = path.basename(file);
|
||||
let fileRows = 0;
|
||||
|
||||
try {
|
||||
const wb = XLSX.readFile(file);
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(ws, { header: 1 });
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
let startRow = 0;
|
||||
let format = null;
|
||||
|
||||
if (isHeaderRow(rows[0])) {
|
||||
format = detectFormat(rows[0]);
|
||||
startRow = 1;
|
||||
}
|
||||
|
||||
for (let i = startRow; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row || row.length < 2) continue;
|
||||
|
||||
try {
|
||||
let record;
|
||||
|
||||
if (format === "separate-scores") {
|
||||
record = processSeparateScoresRow(row);
|
||||
} else if (format && format.type === "mapped") {
|
||||
record = processMappedRow(row, format.map);
|
||||
} else {
|
||||
// No header or unrecognized: assume standard 6-column order
|
||||
record = processMappedRow(row, DEFAULT_MAP);
|
||||
}
|
||||
|
||||
if (!record) continue;
|
||||
insert.run(record);
|
||||
fileRows++;
|
||||
} catch {
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to read ${basename}: ${err.message}`);
|
||||
}
|
||||
|
||||
totalRows += fileRows;
|
||||
console.log(` ${basename}: ${fileRows} rows`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Processing ${files.length} Excel files...\n`);
|
||||
insertAll(files);
|
||||
|
||||
db.exec("VACUUM");
|
||||
|
||||
const count = db.prepare("SELECT COUNT(*) as cnt FROM student").get();
|
||||
console.log(`\nDone! ${count.cnt} students in database.`);
|
||||
console.log(`Errors skipped: ${errorCount}`);
|
||||
console.log(`Output: ${DB_PATH}`);
|
||||
|
||||
const stat = fs.statSync(DB_PATH);
|
||||
console.log(`Size: ${(stat.size / 1024 / 1024).toFixed(1)} MB`);
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,323 @@
|
||||
.app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.8rem;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Search form */
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-form input:focus {
|
||||
border-color: #4361ee;
|
||||
}
|
||||
|
||||
.search-form button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
background: #4361ee;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.search-form button:hover:not(:disabled) {
|
||||
background: #3a56d4;
|
||||
}
|
||||
|
||||
.search-form button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 300px;
|
||||
height: 8px;
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
margin: 0.5rem auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #4361ee;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* Results table */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
thead th {
|
||||
background: #f0f0f5;
|
||||
padding: 0.6rem 0.5rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cumthi-cell {
|
||||
font-size: 0.8rem;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-cell {
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.no-results,
|
||||
.error,
|
||||
.warning {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #e65100;
|
||||
background: #fff3e0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
header h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preset-list {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 + "thptqg2016.db.gz";
|
||||
const MAX_RESULTS = 100;
|
||||
|
||||
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) => {
|
||||
if (!db) return;
|
||||
setSearchError(null);
|
||||
|
||||
try {
|
||||
const isExamId = /^[A-Z]{2,4}\d+$/i.test(query) || /^\d+$/.test(query);
|
||||
let stmt;
|
||||
|
||||
if (isExamId) {
|
||||
stmt = db.prepare(
|
||||
"SELECT * FROM student WHERE so_bao_danh = $q LIMIT $limit",
|
||||
);
|
||||
stmt.bind({ $q: query.toUpperCase(), $limit: MAX_RESULTS });
|
||||
} else {
|
||||
stmt = db.prepare(
|
||||
"SELECT * FROM student WHERE ho_ten LIKE $q LIMIT $limit",
|
||||
);
|
||||
stmt.bind({ $q: `%${query}%`, $limit: MAX_RESULTS });
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
setResults(rows);
|
||||
} catch (err) {
|
||||
setSearchError(err.message);
|
||||
}
|
||||
},
|
||||
[db],
|
||||
);
|
||||
|
||||
// Ctrl+Enter shortcut to execute query 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 2016</h1>
|
||||
<p className="subtitle">
|
||||
Dữ liệu 877.461 thí sinh toàn quốc · Hỗ trợ truy vấn SQL tùy chỉnh
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{loading && (
|
||||
<div className="loading">
|
||||
<p>
|
||||
Đang tải cơ sở dữ liệu...{" "}
|
||||
{progress > 0 ? `${progress}%` : ""}
|
||||
</p>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="error">Lỗi: {error}</p>}
|
||||
|
||||
<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>
|
||||
|
||||
{activeTab === "search" && (
|
||||
<>
|
||||
<SearchForm
|
||||
onSearch={handleSearch}
|
||||
disabled={loading || !!error}
|
||||
/>
|
||||
|
||||
{searchError && (
|
||||
<p className="error">Lỗi truy vấn: {searchError}</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>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
Nguồn: Sưu tầm từ trang báo thời đó · Dữ liệu chỉ mang tính tham
|
||||
khảo
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,218 @@
|
||||
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, ten_cum_thi, toan
|
||||
FROM student WHERE toan IS NOT NULL
|
||||
ORDER BY toan DESC LIMIT 10`,
|
||||
},
|
||||
{
|
||||
label: "Điểm trung bình theo cụm thi",
|
||||
sql: `SELECT ten_cum_thi,
|
||||
COUNT(*) AS so_luong,
|
||||
ROUND(AVG(toan), 2) AS tb_toan,
|
||||
ROUND(AVG(ngu_van), 2) AS tb_van,
|
||||
ROUND(AVG(tieng_anh), 2) AS tb_anh
|
||||
FROM student
|
||||
GROUP BY ten_cum_thi
|
||||
ORDER BY so_luong DESC
|
||||
LIMIT 20`,
|
||||
},
|
||||
{
|
||||
label: "Thống kê theo giới tính",
|
||||
sql: `SELECT gioi_tinh,
|
||||
COUNT(*) AS so_luong,
|
||||
ROUND(AVG(toan), 2) AS tb_toan,
|
||||
ROUND(AVG(ngu_van), 2) AS tb_van
|
||||
FROM student
|
||||
WHERE gioi_tinh IS NOT NULL
|
||||
GROUP BY gioi_tinh`,
|
||||
},
|
||||
{
|
||||
label: "Thí sinh đạt 9+ điểm Toán",
|
||||
sql: `SELECT so_bao_danh, ho_ten, ten_cum_thi, gioi_tinh, 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_duc IS NOT NULL THEN 1 ELSE 0 END) AS tieng_duc,
|
||||
SUM(CASE WHEN tieng_nhat IS NOT NULL THEN 1 ELSE 0 END) AS tieng_nhat,
|
||||
SUM(CASE WHEN tieng_trung IS NOT NULL THEN 1 ELSE 0 END) AS tieng_trung
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
const SUBJECT_COLUMNS = [
|
||||
{ key: "toan", label: "Toán" },
|
||||
{ key: "ngu_van", label: "Ngữ văn" },
|
||||
{ key: "vat_ly", label: "Vật lí" },
|
||||
{ key: "hoa_hoc", label: "Hóa học" },
|
||||
{ key: "sinh_hoc", label: "Sinh học" },
|
||||
{ key: "lich_su", label: "Lịch sử" },
|
||||
{ key: "dia_ly", label: "Địa lí" },
|
||||
{ key: "tieng_anh", label: "T.Anh" },
|
||||
{ key: "tieng_phap", label: "T.Pháp" },
|
||||
{ key: "tieng_duc", label: "T.Đức" },
|
||||
{ key: "tieng_nhat", label: "T.Nhật" },
|
||||
{ key: "tieng_trung", label: "T.Trung" },
|
||||
];
|
||||
|
||||
function formatScore(val) {
|
||||
if (val === null || val === undefined) return "—";
|
||||
return Number(val).toFixed(2);
|
||||
}
|
||||
|
||||
export function ScoreTable({ results }) {
|
||||
if (!results) return null;
|
||||
if (results.length === 0) {
|
||||
return <p className="no-results">Không tìm thấy kết quả.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-wrapper">
|
||||
<p className="result-count">Tìm thấy {results.length} kết quả</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SBD</th>
|
||||
<th>Họ tên</th>
|
||||
<th>Ngày sinh</th>
|
||||
<th>Cụm thi</th>
|
||||
<th>GT</th>
|
||||
{SUBJECT_COLUMNS.map((col) => (
|
||||
<th key={col.key}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((row) => (
|
||||
<tr key={row.so_bao_danh}>
|
||||
<td>{row.so_bao_danh}</td>
|
||||
<td className="name-cell">{row.ho_ten}</td>
|
||||
<td>{row.ngay_sinh || "—"}</td>
|
||||
<td className="cumthi-cell">{row.ten_cum_thi || "—"}</td>
|
||||
<td>{row.gioi_tinh || "—"}</td>
|
||||
{SUBJECT_COLUMNS.map((col) => (
|
||||
<td key={col.key} className="score-cell">
|
||||
{formatScore(row[col.key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function SearchForm({ onSearch, disabled }) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) onSearch(trimmed);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import initSqlJs from "sql.js";
|
||||
|
||||
const SQL_WASM_URL = "https://sql.js.org/dist/sql-wasm.wasm";
|
||||
|
||||
/**
|
||||
* Hook to load a SQLite database from a gzipped URL into sql.js.
|
||||
* Returns { db, loading, error, progress }.
|
||||
*/
|
||||
export function useSqlite(dbUrl) {
|
||||
const [db, setDb] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const dbRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const SQL = await initSqlJs({ locateFile: () => SQL_WASM_URL });
|
||||
|
||||
const response = await fetch(dbUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch database: ${response.status}`);
|
||||
|
||||
const contentLength = +response.headers.get("Content-Length") || 0;
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
if (contentLength > 0) {
|
||||
setProgress(Math.round((received / contentLength) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
let arrayBuffer;
|
||||
|
||||
if (dbUrl.endsWith(".gz")) {
|
||||
const ds = new DecompressionStream("gzip");
|
||||
const decompressed = blob.stream().pipeThrough(ds);
|
||||
const decompressedBlob = await new Response(decompressed).blob();
|
||||
arrayBuffer = await decompressedBlob.arrayBuffer();
|
||||
} else {
|
||||
arrayBuffer = await blob.arrayBuffer();
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const database = new SQL.Database(new Uint8Array(arrayBuffer));
|
||||
dbRef.current = database;
|
||||
setDb(database);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (dbRef.current) {
|
||||
dbRef.current.close();
|
||||
dbRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dbUrl]);
|
||||
|
||||
return { db, loading, error, progress };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: #fafafa;
|
||||
color: #333;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.jsx";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: "/thptqg2016/",
|
||||
});
|
||||
Reference in new issue
Block a user