Merge pull request 'update client for server' (#142) from zelda.push-tracking-tool-web into master

Reviewed-on: #142
This commit is contained in:
zelda 2025-12-11 13:11:33 +11:00
commit b5f5fa1748
16 changed files with 153 additions and 3345 deletions

Binary file not shown.

View File

@ -1,3 +1,5 @@
Run client: npm run dev or npm run build && npm run preview
==> Build client xong => coppy file asset và index vào folder static của server => thêm prefix static vào link của assets trong file index VD: /static/assets
Run server uvicorn main:app --reload

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL = "/"
# VITE_API_BASE_URL = "http://127.0.0.1:8000"

View File

@ -1,7 +1,7 @@
import ax from "axios";
const axios = ax.create({
baseURL: "http://127.0.0.1:8000",
baseURL: import.meta.env.VITE_API_BASE_URL || "/",
});
export default axios;

View File

@ -25,6 +25,8 @@ export default function TabFeatures() {
const [loading, setLoading] = useState(false);
const [checkPoinLoading, setCheckPoinLoading] = useState(false);
const toggleAutoCheck = () => {
if (isAutoChecking) {
if (autoCheckIntervalRef.current) {
@ -41,8 +43,13 @@ export default function TabFeatures() {
};
const createCheckpoint = async () => {
if (!currentUser) return;
if (!currentUser) {
toast.warning("Vui lòng chọn user để tạo checkpoint");
return;
}
try {
setCheckPoinLoading(true);
const file = await capture(videoRef, canvasRef);
const { data } = await checkingApi.register({ user: currentUser, file });
@ -64,6 +71,8 @@ export default function TabFeatures() {
(data.response?.data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
);
} finally {
setCheckPoinLoading(false);
}
};
@ -126,6 +135,8 @@ export default function TabFeatures() {
// ← cách đúng nhất để detect phím cách
e.preventDefault(); // nếu không muốn scroll
if (!loading) return;
captureAndCheck();
}
};
@ -135,10 +146,10 @@ export default function TabFeatures() {
return () => {
window.removeEventListener("keydown", down);
};
}, [captureAndCheck]);
}, [captureAndCheck, loading]);
return (
<div className="absolute bottom-10 px-4 right-0 left-0 grid grid-cols-4 gap-4">
<div className="absolute bottom-10 px-4 right-0 left-0 grid grid-cols-3 gap-4">
<Button
onClick={captureAndCheck}
disabled={isAutoChecking}
@ -162,7 +173,7 @@ export default function TabFeatures() {
isAutoChecking && "animate-pulse"
)}
>
{isAutoChecking ? (
{!loading && isAutoChecking ? (
<>
<Square className="mr-2 size-4" />
Dừng Tự Đng
@ -173,16 +184,26 @@ export default function TabFeatures() {
Tự Đng Điểm Danh
</>
)}
{loading && <Loader className="size-4 animate-spin" />}
</Button>
<Button
disabled={isAutoChecking}
onClick={createCheckpoint}
className={cn("w-full font-semibold")}
>
<Image />
Tạo Check Point
</Button>
{currentUser && (
<Button
disabled={isAutoChecking}
onClick={createCheckpoint}
className={cn("w-full font-semibold")}
>
{!checkPoinLoading && (
<>
<Image />
Tạo Check Point
</>
)}
{checkPoinLoading && <Loader className="size-4 animate-spin" />}
</Button>
)}
{!currentUser && <Register />}
</div>

View File

@ -249,23 +249,23 @@ async def checkin(file: UploadFile = File(...), camera_id: str = Form("cam1"), d
# thêm dô đây
id_log = 0
ms_response = create_history({"name": encoding.name.split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
id_log = ms_response.get('data').get('id')
status = ms_response.get('data').get('status')
# thêm dô đây------------
# id_log = 0
# ms_response = create_history({"name": encoding.name.split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
# id_log = ms_response.get('data').get('id')
# status = ms_response.get('data').get('status')
# reset pointer
file.file.seek(0)
# # reset pointer
# file.file.seek(0)
send_image_res = send_image(
id=id_log,
file=file,
student_name=encoding.name,
status=status
)
# send_image_res = send_image(
# id=id_log,
# file=file,
# student_name=encoding.name,
# status=status
# )
print(id_log, send_image_res)
# print(id_log, send_image_res)
# Insert new checkin
db.execute(

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script
type="module"
crossorigin
src="/static/assets/index-Cbkb3kfK.js"
></script>
<link
rel="stylesheet"
crossorigin
href="/static/assets/index-CvR5W1c8.css"
/>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,432 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Face Check-In / Register</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f7fa;
color: #333;
}
h2 {
color: #2c3e50;
text-align: center;
margin-bottom: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
video {
width: 100%;
max-width: 1200px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: block;
margin: 0 auto;
}
input[type="text"], input[type="email"] {
width: 100%;
padding: 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
transition: border-color 0.3s;
}
input[type="text"]:focus, input[type="email"]:focus {
border-color: #3498db;
outline: none;
box-shadow: 0 0 5px rgba(52, 152, 219, 0.5);
}
button {
background-color: #3498db;
color: white;
border: none;
padding: 12px 20px;
margin: 10px 5px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
button:hover {
background-color: #2980b9;
}
#register {
background-color: #2ecc71;
}
#register:hover {
background-color: #27ae60;
}
.container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 15px;
}
.button-group {
display: flex;
justify-content: center;
margin-top: 20px;
gap: 10px;
}
#auto-checkin {
background-color: #f39c12;
}
#auto-checkin:hover {
background-color: #d35400;
}
#auto-checkin.active {
background-color: #e74c3c;
}
.logs-container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f2f2f2;
color: #333;
font-weight: bold;
}
tr:hover {
background-color: #f5f5f5;
}
.refresh-btn {
background-color: #9b59b6;
margin-bottom: 15px;
}
.refresh-btn:hover {
background-color: #8e44ad;
}
.empty-logs {
text-align: center;
color: #7f8c8d;
padding: 20px;
font-style: italic;
}
.main-layout {
display: flex;
gap: 20px;
max-width: 1600px;
margin: 0 auto;
}
.left-panel {
flex: 6;
}
.right-panel {
flex: 5;
}
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
}
.newest-log {
background-color: #e8f8f5;
font-weight: bold;
animation: highlight 2s ease-in-out;
}
@keyframes highlight {
0% { background-color: #d4efdf; }
50% { background-color: #a9dfbf; }
100% { background-color: #e8f8f5; }
}
.shortcut-hint {
text-align: center;
margin-top: 10px;
color: #7f8c8d;
font-style: italic;
font-size: 14px;
}
.custom-alert {
position: fixed;
top: 20px;
right: 20px;
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
font-family: sans-serif;
font-size: 16px;
z-index: 9999;
animation: slideIn 0.3s ease-out;
}
.alert-success {
background-color: #34d399; /* green-400 */
}
.alert-error {
background-color: #f87171; /* red-400 */
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div class="main-layout">
<div class="left-panel">
<div class="container">
<h2>📸 Face Camera</h2>
<div class="video-container" style="position: relative; display: flex; justify-content: center; align-items: center;">
<video id="video" autoplay style="width: 100%;"></video>
<img src="http://127.0.0.1:8000/static/face-removebg-preview.png" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 60%; height: auto; pointer-events: none; opacity: 0.5;" alt="Hướng dẫn nhận diện khuôn mặt">
</div>
<br>
<div class="form-group">
<input type="text" id="name" placeholder="Tên học sinh (khi đăng ký)">
</div>
<div class="form-group">
<input type="email" id="email" placeholder="Email học sinh (bắt buộc)">
</div>
<div class="button-group">
<button id="register">📥 Đăng ký khuôn mặt</button>
<button id="checkin">✅ Điểm danh</button>
<button id="auto-checkin">🔄 Tự động điểm danh</button>
</div>
<div class="shortcut-hint">
Nhấn phím Space để điểm danh nhanh
</div>
</div>
</div>
<div class="right-panel">
<div class="logs-container">
<h2>📋 Lịch sử điểm danh</h2>
<button id="refresh-logs" class="refresh-btn">🔄 Làm mới dữ liệu</button>
<div id="logs-table-container">
<table id="logs-table">
<thead>
<tr>
<th>Tên học sinh</th>
<th>Thời gian</th>
<th>Camera ID</th>
</tr>
</thead>
<tbody id="logs-body">
<!-- Dữ liệu logs sẽ được thêm vào đây -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<canvas id="canvas" width="1200" height="900" style="display:none;"></canvas>
<script>
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// Mở camera
navigator.mediaDevices.getUserMedia({ video: true })
.then((stream) => {
video.srcObject = stream;
})
.catch((err) => {
showAlert("Không mở được camera: " + err);
});
function showAlert(text, status = 'error') {
const alertDiv = document.createElement('div');
alertDiv.className = `custom-alert alert-${status}`;
alertDiv.innerText = text;
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.style.opacity = '0';
alertDiv.style.transition = 'opacity 0.5s ease-out';
setTimeout(() => {
alertDiv.remove();
}, 500);
}, 2000);
}
// Hàm chụp ảnh từ video và gửi đến API
function sendImage(url, extraData = {}) {
context.drawImage(video, 0, 0, 1200, 900);
canvas.toBlob((blob) => {
const formData = new FormData();
formData.append("file", blob, "frame.jpg");
for (const [key, value] of Object.entries(extraData)) {
formData.append(key, value);
}
fetch(url, {
method: "POST",
body: formData
})
.then(res => res.json())
.then(data => {
const status = data.message && data.message.includes("successful") ? 'success' : 'error';
showAlert(data.message || JSON.stringify(data), status);
if (url === "/checkin" && data.message && data.message.includes("successful")) {
loadLogs(); // Tải lại logs sau khi check-in thành công
}
})
.catch(err => showAlert("Lỗi gửi ảnh: " + err, 'error'));
}, "image/jpeg");
}
// Sự kiện: Đăng ký
document.getElementById('register').addEventListener('click', () => {
const name = document.getElementById('name').value.trim();
const email = document.getElementById('email').value.trim();
if (!name || !email) {
showAlert("Vui lòng nhập cả tên và email.", 'error');
return;
}
sendImage("/register", { name, email });
});
let autoCheckinInterval = null;
const autoCheckinButton = document.getElementById('auto-checkin');
// Hàm bật/tắt tự động điểm danh
function toggleAutoCheckin() {
if (autoCheckinInterval) {
// Tắt tự động
clearInterval(autoCheckinInterval);
autoCheckinInterval = null;
autoCheckinButton.classList.remove('active');
autoCheckinButton.textContent = '🔄 Tự động điểm danh';
} else {
// Bật tự động
autoCheckinInterval = setInterval(() => {
sendImage("/checkin", { camera_id: "webcam" });
}, 1000); // Gửi mỗi giây
autoCheckinButton.classList.add('active');
autoCheckinButton.textContent = '⏹️ Dừng tự động';
}
}
// Sự kiện: Bật/tắt tự động điểm danh
autoCheckinButton.addEventListener('click', toggleAutoCheckin);
// Sự kiện: Check-in
document.getElementById('checkin').addEventListener('click', () => {
if (autoCheckinInterval) {
toggleAutoCheckin(); // Tắt tự động nếu đang bật
}
sendImage("/checkin", { camera_id: "webcam" });
});
// Sự kiện: Nhấn phím Space để điểm danh
document.addEventListener('keydown', (event) => {
if (event.code === 'Space' || event.keyCode === 32) {
event.preventDefault();
if (autoCheckinInterval) {
toggleAutoCheckin(); // Tắt tự động nếu đang bật
}
sendImage("/checkin", { camera_id: "webcam" });
}
});
// Hàm tải logs từ server
function loadLogs() {
fetch("/logs")
.then(res => res.json())
.then(logs => {
const logsBody = document.getElementById('logs-body');
logsBody.innerHTML = '';
if (logs.length === 0) {
logsBody.innerHTML = '<tr><td colspan="3" class="empty-logs">Chưa có dữ liệu điểm danh</td></tr>';
return;
}
// Sắp xếp logs từ mới đến cũ
logs.sort((a, b) => new Date(b.time) - new Date(a.time));
logs.forEach((log, index) => {
// Highlight log mới nhất
const isNewest = index === 0;
const row = document.createElement('tr');
if (isNewest) {
row.classList.add('newest-log');
}
row.innerHTML = `
<td>${log.name}</td>
<td>${log.time}</td>
<td>${log.camera_id}</td>
`;
logsBody.appendChild(row);
});
})
.catch(err => {
console.error("Lỗi tải logs:", err);
document.getElementById('logs-body').innerHTML =
'<tr><td colspan="3" class="empty-logs">Lỗi khi tải dữ liệu</td></tr>';
});
}
// Sự kiện: Làm mới logs
document.getElementById('refresh-logs').addEventListener('click', loadLogs);
// Tải logs khi trang được tải
document.addEventListener('DOMContentLoaded', loadLogs);
</script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script
type="module"
crossorigin
src="/static/assets/index-BvxJK8_6.js"
></script>
<link
rel="stylesheet"
crossorigin
href="/static/assets/index-CDZdzCu6.css"
/>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 222 KiB