Compare commits
24 Commits
8803cd622a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b6dfe705f5 | |||
| 42172db9f4 | |||
| 3b80550c76 | |||
| 2bdec7d7e7 | |||
| 232f9873a0 | |||
| 6ee9773f7f | |||
| aed692cffe | |||
| 1ed246eec8 | |||
| a55b0a4567 | |||
| f4b9d99051 | |||
| 487e9eb8ea | |||
| 7d3fdefa3f | |||
| 0ff025fb15 | |||
| b56c6d722c | |||
| 83821b6b82 | |||
| 31943bf489 | |||
| 24a4ef2c66 | |||
| 120ff1f3c8 | |||
| 84ad60841c | |||
| bad51585db | |||
| 06f2edb195 | |||
| 0583c7869a | |||
| b4c3155564 | |||
| 58c96609b6 |
417
app_pool.sql
Normal file
417
app_pool.sql
Normal file
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
Navicat Premium Dump SQL
|
||||
|
||||
Source Server : RAIA_V3
|
||||
Source Server Type : MySQL
|
||||
Source Server Version : 80043 (8.0.43-0ubuntu0.24.04.1)
|
||||
Source Host : 36.50.55.224:3306
|
||||
Source Schema : simple_care
|
||||
|
||||
Target Server Type : MySQL
|
||||
Target Server Version : 80043 (8.0.43-0ubuntu0.24.04.1)
|
||||
File Encoding : 65001
|
||||
|
||||
Date: 13/07/2026 11:02:24
|
||||
*/
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for app_pool
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `app_pool`;
|
||||
CREATE TABLE `app_pool` (
|
||||
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`created_at` datetime(3) NULL DEFAULT NULL,
|
||||
`updated_at` datetime(3) NULL DEFAULT NULL,
|
||||
`process_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`process_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`window_title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
|
||||
`keyword` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`hit_count` bigint NULL DEFAULT 1,
|
||||
`last_seen_at` datetime(3) NULL DEFAULT NULL,
|
||||
`last_student_rk_id` bigint NULL DEFAULT NULL,
|
||||
`last_class_rk_id` bigint NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `idx_app_pool_process_key`(`process_key` ASC) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 374 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of app_pool
|
||||
-- ----------------------------
|
||||
INSERT INTO `app_pool` VALUES (1, '2026-07-01 08:14:10.265', '2026-07-13 09:21:02.887', 'Termius.exe', 'termius.exe', 'Termius - SFTP', 'termius', 8, '2026-07-13 09:21:02.886', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (2, '2026-07-01 08:14:12.673', '2026-07-13 10:04:15.216', 'parsecd.exe', 'parsecd.exe', 'Parsec', 'parsecd', 2, '2026-07-13 10:04:15.215', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (3, '2026-07-01 08:14:14.280', '2026-07-13 09:21:38.888', 'Lark.exe', 'lark.exe', 'Lark', 'lark', 627, '2026-07-13 09:21:38.888', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (4, '2026-07-01 08:36:14.169', '2026-07-10 09:40:29.649', 'POWERPNT.EXE', 'powerpnt.exe', 'PowerPoint', 'powerpnt', 17, '2026-07-10 09:40:29.648', 1718, 82);
|
||||
INSERT INTO `app_pool` VALUES (5, '2026-07-01 08:36:19.650', '2026-07-09 09:36:27.131', 'FoxitPDFReader.exe', 'foxitpdfreader.exe', 'Foxit PDF Reader', 'foxitpdfreader', 7, '2026-07-09 09:36:27.129', 1813, 82);
|
||||
INSERT INTO `app_pool` VALUES (6, '2026-07-01 08:36:22.154', '2026-07-13 10:14:51.742', 'Zalo.exe', 'zalo.exe', 'Zalo', 'zalo', 252, '2026-07-13 10:14:51.741', 1770, 82);
|
||||
INSERT INTO `app_pool` VALUES (7, '2026-07-01 08:36:53.186', '2026-07-13 08:49:31.545', 'chrome.exe', 'chrome.exe', 'Thẻ mới - Google Chrome', 'chrome', 207, '2026-07-13 08:49:31.544', 1340, 77);
|
||||
INSERT INTO `app_pool` VALUES (8, '2026-07-01 08:44:12.805', '2026-07-13 08:59:49.288', 'brave.exe', 'brave.exe', 'MinhMario/hackathon-api - Brave', 'brave', 90, '2026-07-13 08:59:49.287', 1523, 77);
|
||||
INSERT INTO `app_pool` VALUES (9, '2026-07-01 08:44:18.806', '2026-07-13 08:55:43.648', 'msedge.exe', 'msedge.exe', 'Rikkei Education - Personal - Microsoft Edge', 'msedge', 348, '2026-07-13 08:55:43.647', 1640, 77);
|
||||
INSERT INTO `app_pool` VALUES (10, '2026-07-01 08:47:54.552', '2026-07-01 08:47:54.552', 'olk.exe', 'olk.exe', 'Outlook', 'olk', 1, '2026-07-01 08:47:54.551', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (11, '2026-07-01 08:48:00.544', '2026-07-06 10:20:31.668', 'InetMgr.exe', 'inetmgr.exe', 'Internet Information Services (IIS) Manager', 'inetmgr', 3, '2026-07-06 10:20:31.665', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (12, '2026-07-01 08:52:40.955', '2026-07-10 14:23:15.698', 'UltraViewer_Desktop.exe', 'ultraviewer_desktop.exe', 'UltraViewer 6.6.124 - Free', 'ultraviewer_desktop', 23, '2026-07-10 14:23:15.697', 1568, 142);
|
||||
INSERT INTO `app_pool` VALUES (13, '2026-07-01 08:55:25.499', '2026-07-13 10:11:16.331', 'Discord.exe', 'discord.exe', 'Friends - Discord', 'discord', 48, '2026-07-13 10:11:16.331', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (14, '2026-07-01 09:07:16.934', '2026-07-01 09:07:16.934', 'partitionwizard.exe', 'partitionwizard.exe', 'MiniTool Partition Wizard Free 13.6', 'partitionwizard', 1, '2026-07-01 09:07:16.934', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (15, '2026-07-01 09:07:25.937', '2026-07-02 12:39:09.292', 'firefox.exe', 'firefox.exe', 'Google Drive – Cảnh báo quét vi rút — Hồ sơ 1 — Mozilla Firefox', 'firefox', 7, '2026-07-02 12:39:09.291', 173, 156);
|
||||
INSERT INTO `app_pool` VALUES (16, '2026-07-01 09:15:31.940', '2026-07-01 09:15:31.940', 'OpenKey64.exe', 'openkey64.exe', 'OpenKey 2.0.5 - Bộ gõ Tiếng Việt', 'openkey64', 1, '2026-07-01 09:15:31.938', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (17, '2026-07-01 10:15:39.138', '2026-07-01 10:15:39.138', 'Transporter', 'transporter', 'Transporter', 'transporter', 1, '2026-07-01 10:15:39.137', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (18, '2026-07-01 10:15:39.140', '2026-07-13 08:14:25.071', 'Safari', 'safari', 'Safari', 'safari', 36, '2026-07-13 08:14:25.070', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (19, '2026-07-01 10:15:39.147', '2026-07-13 10:19:46.582', 'Zalo', 'zalo', 'Zalo', 'zalo', 40, '2026-07-13 10:19:46.581', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (20, '2026-07-01 10:15:39.152', '2026-07-13 09:21:51.901', 'Electron', 'electron', 'Electron', 'electron', 28, '2026-07-13 09:21:51.898', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (21, '2026-07-01 10:15:53.650', '2026-07-10 07:03:12.128', 'parsecd', 'parsecd', 'parsecd', 'parsecd', 5, '2026-07-10 07:03:12.127', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (22, '2026-07-01 10:16:38.620', '2026-07-01 10:16:38.620', 'GunnyChill', 'gunnychill', 'GunnyChill', 'gunnychill', 1, '2026-07-01 10:16:38.619', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (23, '2026-07-01 10:17:02.639', '2026-07-06 14:34:15.337', 'Photos', 'photos', 'Photos', 'photos', 3, '2026-07-06 14:34:15.336', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (24, '2026-07-01 10:17:35.947', '2026-07-03 12:26:50.390', 'System Settings', 'system settings', 'System Settings', 'system settings', 27, '2026-07-03 12:26:50.389', 88, 156);
|
||||
INSERT INTO `app_pool` VALUES (25, '2026-07-01 12:21:08.207', '2026-07-01 12:21:08.207', 'App Store', 'app store', 'App Store', 'app store', 1, '2026-07-01 12:21:08.206', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (26, '2026-07-01 15:29:00.569', '2026-07-06 15:34:16.984', 'Slack', 'slack', 'Slack', 'slack', 3, '2026-07-06 15:34:16.982', 1661, 142);
|
||||
INSERT INTO `app_pool` VALUES (27, '2026-07-01 15:29:00.569', '2026-07-13 07:34:27.525', 'Lark', 'lark', 'Lark', 'lark', 40, '2026-07-13 07:34:27.523', 289, 42);
|
||||
INSERT INTO `app_pool` VALUES (28, '2026-07-01 15:29:00.604', '2026-07-01 15:29:00.604', 'Numbers', 'numbers', 'Numbers', 'numbers', 1, '2026-07-01 15:29:00.603', 1459, 143);
|
||||
INSERT INTO `app_pool` VALUES (29, '2026-07-01 15:29:00.608', '2026-07-01 15:29:00.608', 'Obsidian', 'obsidian', 'Obsidian', 'obsidian', 1, '2026-07-01 15:29:00.607', 1459, 143);
|
||||
INSERT INTO `app_pool` VALUES (30, '2026-07-01 15:29:00.611', '2026-07-13 09:17:30.680', 'TextEdit', 'textedit', 'TextEdit', 'textedit', 9, '2026-07-13 09:17:30.679', 644, 144);
|
||||
INSERT INTO `app_pool` VALUES (31, '2026-07-01 15:29:00.678', '2026-07-01 15:29:00.678', 'Termius', 'termius', 'Termius', 'termius', 1, '2026-07-01 15:29:00.677', 1459, 143);
|
||||
INSERT INTO `app_pool` VALUES (32, '2026-07-01 15:29:00.678', '2026-07-13 07:55:54.789', 'Notes', 'notes', 'Notes', 'notes', 26, '2026-07-13 07:55:54.788', 1675, 75);
|
||||
INSERT INTO `app_pool` VALUES (33, '2026-07-01 15:29:00.695', '2026-07-03 12:18:21.310', 'Postman', 'postman', 'Postman', 'postman', 5, '2026-07-03 12:18:21.309', 111, 49);
|
||||
INSERT INTO `app_pool` VALUES (34, '2026-07-01 15:29:37.629', '2026-07-13 07:03:58.538', 'Messages', 'messages', 'Messages', 'messages', 4, '2026-07-13 07:03:58.537', 626, 63);
|
||||
INSERT INTO `app_pool` VALUES (35, '2026-07-01 15:29:55.637', '2026-07-08 12:06:07.339', 'Microsoft Edge', 'microsoft edge', 'Microsoft Edge', 'microsoft edge', 5, '2026-07-08 12:06:07.339', 1661, 142);
|
||||
INSERT INTO `app_pool` VALUES (36, '2026-07-01 15:30:07.601', '2026-07-13 08:59:00.180', 'Music', 'music', 'Music', 'music', 17, '2026-07-13 08:59:00.179', 644, 144);
|
||||
INSERT INTO `app_pool` VALUES (37, '2026-07-01 16:13:03.116', '2026-07-03 14:58:13.456', 'ApplicationFrameHost.exe', 'applicationframehost.exe', 'Camera', 'applicationframehost', 421, '2026-07-03 14:58:13.455', 1326, 142);
|
||||
INSERT INTO `app_pool` VALUES (38, '2026-07-01 16:13:03.174', '2026-07-13 08:25:32.105', 'Notepad.exe', 'notepad.exe', 'venv.txt - Notepad', 'notepad', 155, '2026-07-13 08:25:32.105', 1437, 75);
|
||||
INSERT INTO `app_pool` VALUES (39, '2026-07-01 16:13:03.184', '2026-07-13 07:39:37.502', 'WINWORD.EXE', 'winword.exe', 'New Microsoft Word Document - Word', 'winword', 107, '2026-07-13 07:39:37.501', 642, 144);
|
||||
INSERT INTO `app_pool` VALUES (40, '2026-07-01 16:44:12.156', '2026-07-13 09:21:51.904', 'MySQLWorkbench', 'mysqlworkbench', 'MySQLWorkbench', 'mysqlworkbench', 11, '2026-07-13 09:21:51.903', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (41, '2026-07-01 18:44:17.370', '2026-07-08 14:23:48.834', 'IDMan.exe', 'idman.exe', 'Internet Download Manager 6.43', 'idman', 11, '2026-07-08 14:23:48.833', 195, 49);
|
||||
INSERT INTO `app_pool` VALUES (42, '2026-07-01 20:50:16.973', '2026-07-13 09:18:08.284', 'browser.exe', 'browser.exe', 'Thẻ mới - Cốc Cốc', 'browser', 237, '2026-07-13 09:18:08.283', 1590, 144);
|
||||
INSERT INTO `app_pool` VALUES (43, '2026-07-01 20:52:00.153', '2026-07-13 07:28:48.124', 'opera.exe', 'opera.exe', '(4) WINDOW Hướng dẫn tải, cài đặt chi tiết - YouTube - Opera', 'opera', 79, '2026-07-13 07:28:48.123', 1402, 77);
|
||||
INSERT INTO `app_pool` VALUES (44, '2026-07-01 21:48:07.476', '2026-07-02 07:00:12.236', 'GoTiengViet', 'gotiengviet', 'GoTiengViet', 'gotiengviet', 2, '2026-07-02 07:00:12.235', 592, 63);
|
||||
INSERT INTO `app_pool` VALUES (45, '2026-07-02 07:00:06.796', '2026-07-03 16:16:29.069', 'WindowsTerminal.exe', 'windowsterminal.exe', 'C:\\WINDOWS\\system32\\cmd.exe', 'windowsterminal', 58, '2026-07-03 16:16:29.068', 1466, 142);
|
||||
INSERT INTO `app_pool` VALUES (46, '2026-07-02 07:00:06.797', '2026-07-03 15:55:07.518', 'Postman.exe', 'postman.exe', 'Postman', 'postman', 21, '2026-07-03 15:55:07.515', 276, 51);
|
||||
INSERT INTO `app_pool` VALUES (47, '2026-07-02 07:00:06.803', '2026-07-13 09:16:46.738', 'MySQLWorkbench.exe', 'mysqlworkbench.exe', 'MySQL Workbench', 'mysqlworkbench', 123, '2026-07-13 09:16:46.737', 1572, 144);
|
||||
INSERT INTO `app_pool` VALUES (48, '2026-07-02 07:00:06.804', '2026-07-02 07:00:06.804', 'Docker Desktop.exe', 'docker desktop.exe', 'Containers - Docker Desktop', 'docker desktop', 1, '2026-07-02 07:00:06.803', 613, 63);
|
||||
INSERT INTO `app_pool` VALUES (49, '2026-07-02 07:00:10.234', '2026-07-13 07:03:36.228', 'Activity Monitor', 'activity monitor', 'Activity Monitor', 'activity monitor', 9, '2026-07-13 07:03:36.227', 1675, 75);
|
||||
INSERT INTO `app_pool` VALUES (50, '2026-07-02 07:00:10.234', '2026-07-13 07:00:04.332', 'Passwords', 'passwords', 'Passwords', 'passwords', 2, '2026-07-13 07:00:04.331', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (51, '2026-07-02 07:00:10.235', '2026-07-10 07:03:12.292', 'Microsoft Word', 'microsoft word', 'Microsoft Word', 'microsoft word', 17, '2026-07-10 07:03:12.291', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (52, '2026-07-02 07:00:10.263', '2026-07-02 07:30:31.280', 'steam_osx', 'steam_osx', 'steam_osx', 'steam_osx', 2, '2026-07-02 07:30:31.279', 186, 51);
|
||||
INSERT INTO `app_pool` VALUES (53, '2026-07-02 07:00:10.264', '2026-07-02 07:02:39.978', 'TeamViewer', 'teamviewer', 'TeamViewer', 'teamviewer', 8, '2026-07-02 07:02:39.977', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (54, '2026-07-02 07:00:10.265', '2026-07-13 07:03:36.223', 'Spotify', 'spotify', 'Spotify', 'spotify', 9, '2026-07-13 07:03:36.222', 1675, 75);
|
||||
INSERT INTO `app_pool` VALUES (55, '2026-07-02 07:00:10.630', '2026-07-13 07:27:45.144', 'Spotify.exe', 'spotify.exe', 'Spotify Premium', 'spotify', 41, '2026-07-13 07:27:45.143', 1302, 77);
|
||||
INSERT INTO `app_pool` VALUES (56, '2026-07-02 07:00:11.174', '2026-07-10 07:59:26.688', 'DCv2.exe', 'dcv2.exe', 'MSI Center', 'dcv2', 13, '2026-07-10 07:59:26.687', 174, 74);
|
||||
INSERT INTO `app_pool` VALUES (57, '2026-07-02 07:00:14.504', '2026-07-09 10:24:41.405', 'ChatGPT.exe', 'chatgpt.exe', 'ChatGPT', 'chatgpt', 20, '2026-07-09 10:24:41.404', 1770, 82);
|
||||
INSERT INTO `app_pool` VALUES (58, '2026-07-02 07:00:47.392', '2026-07-03 12:21:25.458', 'Codex.exe', 'codex.exe', 'Codex', 'codex', 6, '2026-07-03 12:21:25.457', 195, 49);
|
||||
INSERT INTO `app_pool` VALUES (59, '2026-07-02 07:01:27.352', '2026-07-02 07:02:12.353', 'RobloxPlayerBeta.exe', 'robloxplayerbeta.exe', 'Roblox', 'robloxplayerbeta', 2, '2026-07-02 07:02:12.352', 1448, 143);
|
||||
INSERT INTO `app_pool` VALUES (60, '2026-07-02 07:01:27.887', '2026-07-10 08:56:03.917', 'iPhone Mirroring', 'iphone mirroring', 'iPhone Mirroring', 'iphone mirroring', 13, '2026-07-10 08:56:03.916', 640, 63);
|
||||
INSERT INTO `app_pool` VALUES (61, '2026-07-02 07:02:00.560', '2026-07-09 13:38:35.910', 'WinRAR.exe', 'winrar.exe', 'document_management_api.zip (evaluation copy)', 'winrar', 51, '2026-07-09 13:38:35.909', 1648, 141);
|
||||
INSERT INTO `app_pool` VALUES (62, '2026-07-02 07:02:44.400', '2026-07-09 07:02:49.059', 'wallpaperui.exe', 'wallpaperui.exe', 'Wallpaper UI', 'wallpaperui', 4, '2026-07-09 07:02:49.058', 632, 63);
|
||||
INSERT INTO `app_pool` VALUES (63, '2026-07-02 07:02:45.356', '2026-07-02 07:02:45.356', 'ceregreset.exe', 'ceregreset.exe', 'Registry Reset', 'ceregreset', 1, '2026-07-02 07:02:45.355', 680, 63);
|
||||
INSERT INTO `app_pool` VALUES (64, '2026-07-02 07:02:49.036', '2026-07-02 07:02:49.036', 'TeamViewerUninstaller', 'teamvieweruninstaller', 'TeamViewerUninstaller', 'teamvieweruninstaller', 1, '2026-07-02 07:02:49.035', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (65, '2026-07-02 07:04:09.708', '2026-07-13 07:00:04.315', 'DeskIn', 'deskin', 'DeskIn', 'deskin', 7, '2026-07-13 07:00:04.314', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (66, '2026-07-02 07:04:29.663', '2026-07-10 14:26:48.669', 'Cloudflare WARP.exe', 'cloudflare warp.exe', 'Cloudflare WARP', 'cloudflare warp', 4, '2026-07-10 14:26:48.668', 1470, 141);
|
||||
INSERT INTO `app_pool` VALUES (67, '2026-07-02 07:04:51.359', '2026-07-10 08:09:48.695', 'laragon.exe', 'laragon.exe', 'laragon', 'laragon', 9, '2026-07-10 08:09:48.695', 622, 63);
|
||||
INSERT INTO `app_pool` VALUES (68, '2026-07-02 07:05:09.359', '2026-07-02 15:33:17.304', 'mmc.exe', 'mmc.exe', 'Device Manager', 'mmc', 5, '2026-07-02 15:33:17.303', 399, 49);
|
||||
INSERT INTO `app_pool` VALUES (69, '2026-07-02 07:05:48.385', '2026-07-13 08:00:41.496', 'egui.exe', 'egui.exe', 'Thông báo - ESET NOD32 Antivirus', 'egui', 6, '2026-07-13 08:00:41.495', 1459, 143);
|
||||
INSERT INTO `app_pool` VALUES (70, '2026-07-02 07:06:11.189', '2026-07-03 13:46:32.564', 'PhoneExperienceHost.exe', 'phoneexperiencehost.exe', 'Phone Link', 'phoneexperiencehost', 5, '2026-07-03 13:46:32.564', 1563, 78);
|
||||
INSERT INTO `app_pool` VALUES (71, '2026-07-02 07:06:29.321', '2026-07-13 07:48:09.800', 'Google Chrome', 'google chrome', 'Google Chrome', 'google chrome', 11, '2026-07-13 07:48:09.800', 1675, 75);
|
||||
INSERT INTO `app_pool` VALUES (72, '2026-07-02 07:07:03.991', '2026-07-02 07:07:03.991', 'GSAutoClicker.exe', 'gsautoclicker.exe', 'GS Auto Clicker 3.1.2', 'gsautoclicker', 1, '2026-07-02 07:07:03.990', 1627, 143);
|
||||
INSERT INTO `app_pool` VALUES (73, '2026-07-02 07:07:10.439', '2026-07-13 09:48:19.011', 'mscopilot.exe', 'mscopilot.exe', 'Copilot', 'mscopilot', 125, '2026-07-13 09:48:19.010', 1698, 82);
|
||||
INSERT INTO `app_pool` VALUES (74, '2026-07-02 07:07:30.197', '2026-07-13 07:46:26.758', 'LenovoVantage.exe', 'lenovovantage.exe', 'Lenovo Vantage', 'lenovovantage', 18, '2026-07-13 07:46:26.757', 1289, 75);
|
||||
INSERT INTO `app_pool` VALUES (75, '2026-07-02 07:08:48.042', '2026-07-09 14:06:03.752', 'claude.exe', 'claude.exe', 'Claude', 'claude', 10, '2026-07-09 14:06:03.750', 1476, 141);
|
||||
INSERT INTO `app_pool` VALUES (76, '2026-07-02 07:10:10.589', '2026-07-07 07:53:21.254', 'mc-web-view.exe', 'mc-web-view.exe', 'McAfee', 'mc-web-view', 6, '2026-07-07 07:53:21.253', 1781, 84);
|
||||
INSERT INTO `app_pool` VALUES (77, '2026-07-02 07:12:44.599', '2026-07-02 07:14:29.620', 'seccenter.exe', 'seccenter.exe', 'Manage exceptions', 'seccenter', 6, '2026-07-02 07:14:29.620', 179, 51);
|
||||
INSERT INTO `app_pool` VALUES (78, '2026-07-02 07:13:17.117', '2026-07-03 12:50:25.406', 'dwm.exe', 'dwm.exe', 'srs.docx - Word', 'dwm', 3, '2026-07-03 12:50:25.405', 195, 49);
|
||||
INSERT INTO `app_pool` VALUES (79, '2026-07-02 07:14:12.249', '2026-07-13 08:30:25.046', 'Photos.exe', 'photos.exe', '20260625-103229.png', 'photos', 42, '2026-07-13 08:30:25.044', 1587, 75);
|
||||
INSERT INTO `app_pool` VALUES (80, '2026-07-02 07:14:33.769', '2026-07-08 15:27:22.931', 'msiexec.exe', 'msiexec.exe', 'Node.js Setup', 'msiexec', 13, '2026-07-08 15:27:22.930', 150, 49);
|
||||
INSERT INTO `app_pool` VALUES (81, '2026-07-02 07:14:48.700', '2026-07-03 15:29:32.674', 'consent.exe', 'consent.exe', 'User Account Control', 'consent', 4, '2026-07-03 15:29:32.673', 190, 51);
|
||||
INSERT INTO `app_pool` VALUES (82, '2026-07-02 07:17:12.369', '2026-07-02 07:17:12.369', 'hola_cr.exe', 'hola_cr.exe', 'Hola App', 'hola_cr', 1, '2026-07-02 07:17:12.368', 1448, 143);
|
||||
INSERT INTO `app_pool` VALUES (83, '2026-07-02 07:19:43.065', '2026-07-03 15:42:19.033', 'mintty.exe', 'mintty.exe', 'MINGW64:/d/hocki02-cntt6/fast-api/demo ', 'mintty', 15, '2026-07-03 15:42:19.032', 1423, 142);
|
||||
INSERT INTO `app_pool` VALUES (84, '2026-07-02 07:21:09.312', '2026-07-02 07:21:09.312', 'LoadingBayInstaller.exe', 'loadingbayinstaller.exe', 'LoadingBayInstaller', 'loadingbayinstaller', 1, '2026-07-02 07:21:09.311', 1620, 143);
|
||||
INSERT INTO `app_pool` VALUES (85, '2026-07-02 07:21:20.052', '2026-07-03 13:35:45.349', 'df_garena_launcher.exe', 'df_garena_launcher.exe', 'Delta Force', 'df_garena_launcher', 3, '2026-07-03 13:35:45.349', 68, 49);
|
||||
INSERT INTO `app_pool` VALUES (86, '2026-07-02 07:25:56.720', '2026-07-07 14:24:40.498', 'devcpp.exe', 'devcpp.exe', 'Dev-C++', 'devcpp', 4, '2026-07-07 14:24:40.497', 1521, 72);
|
||||
INSERT INTO `app_pool` VALUES (87, '2026-07-02 07:29:12.983', '2026-07-02 15:00:13.603', 'CareCenter.exe', 'carecenter.exe', 'Care Center', 'carecenter', 2, '2026-07-02 15:00:13.602', 211, 49);
|
||||
INSERT INTO `app_pool` VALUES (88, '2026-07-02 07:32:19.647', '2026-07-13 09:00:05.197', 'SnippingTool.exe', 'snippingtool.exe', 'Snipping Tool', 'snippingtool', 35, '2026-07-13 09:00:05.196', 611, 144);
|
||||
INSERT INTO `app_pool` VALUES (89, '2026-07-02 07:48:18.980', '2026-07-06 12:28:09.797', 'Calendar', 'calendar', 'Calendar', 'calendar', 3, '2026-07-06 12:28:09.796', 1719, 83);
|
||||
INSERT INTO `app_pool` VALUES (90, '2026-07-02 08:06:53.669', '2026-07-03 16:00:18.038', 'CredentialUIBroker.exe', 'credentialuibroker.exe', 'Windows Security', 'credentialuibroker', 2, '2026-07-03 16:00:18.038', 1474, 142);
|
||||
INSERT INTO `app_pool` VALUES (91, '2026-07-02 08:11:32.691', '2026-07-03 16:08:41.213', 'PickerHost.exe', 'pickerhost.exe', 'Windows Security', 'pickerhost', 19, '2026-07-03 16:08:41.213', 1331, 142);
|
||||
INSERT INTO `app_pool` VALUES (92, '2026-07-02 08:22:03.384', '2026-07-09 07:00:07.509', 'NitroSense.exe', 'nitrosense.exe', 'NitroSense ', 'nitrosense', 16, '2026-07-09 07:00:07.508', 621, 63);
|
||||
INSERT INTO `app_pool` VALUES (93, '2026-07-02 08:26:43.400', '2026-07-06 15:01:53.107', 'photolaunch.exe', 'photolaunch.exe', 'back (0-00-00-00).png WPS Photos', 'photolaunch', 4, '2026-07-06 15:01:53.106', 1576, 141);
|
||||
INSERT INTO `app_pool` VALUES (94, '2026-07-02 08:39:29.967', '2026-07-03 13:52:16.884', 'SoftwareUpdate.exe', 'softwareupdate.exe', 'Cập nhật phần mềm Apple', 'softwareupdate', 2, '2026-07-03 13:52:16.883', 399, 49);
|
||||
INSERT INTO `app_pool` VALUES (95, '2026-07-02 08:50:36.663', '2026-07-13 08:12:33.216', 'EaseOfAccessDialog.exe', 'easeofaccessdialog.exe', 'Filter Keys', 'easeofaccessdialog', 10, '2026-07-13 08:12:33.215', 1525, 77);
|
||||
INSERT INTO `app_pool` VALUES (96, '2026-07-02 09:32:38.105', '2026-07-13 09:25:21.628', 'EXCEL.EXE', 'excel.exe', 'Template-Technical [Protected View] - Excel', 'excel', 24, '2026-07-13 09:25:21.627', 308, 156);
|
||||
INSERT INTO `app_pool` VALUES (97, '2026-07-02 09:32:54.385', '2026-07-02 16:34:51.878', 'OneDrive.exe', 'onedrive.exe', 'Microsoft OneDrive', 'onedrive', 4, '2026-07-02 16:34:51.877', 1737, 84);
|
||||
INSERT INTO `app_pool` VALUES (98, '2026-07-02 09:58:06.389', '2026-07-10 14:25:50.216', 'GetHelp.exe', 'gethelp.exe', 'Nhận Trợ giúp', 'gethelp', 5, '2026-07-10 14:25:50.216', 1598, 142);
|
||||
INSERT INTO `app_pool` VALUES (99, '2026-07-02 11:10:22.535', '2026-07-13 09:03:02.818', 'wps.exe', 'wps.exe', 'WPS Office', 'wps', 27, '2026-07-13 09:03:02.817', 617, 144);
|
||||
INSERT INTO `app_pool` VALUES (100, '2026-07-02 12:12:18.322', '2026-07-08 12:41:41.425', 'Dia', 'dia', 'Dia', 'dia', 35, '2026-07-08 12:41:41.424', 88, 156);
|
||||
INSERT INTO `app_pool` VALUES (101, '2026-07-02 12:12:47.084', '2026-07-09 12:10:12.278', 'zoom.us', 'zoom.us', 'zoom.us', 'zoom.us', 2, '2026-07-09 12:10:12.278', 303, 156);
|
||||
INSERT INTO `app_pool` VALUES (102, '2026-07-02 12:14:07.322', '2026-07-13 07:00:08.059', 'window_raia_c.exe', 'window_raia_c.exe', 'Raia Auth', 'window_raia_c', 14, '2026-07-13 07:00:08.059', 1334, 75);
|
||||
INSERT INTO `app_pool` VALUES (103, '2026-07-02 12:15:19.146', '2026-07-13 08:22:50.727', 'Brave Browser', 'brave browser', 'Brave Browser', 'brave browser', 8, '2026-07-13 08:22:50.726', 289, 42);
|
||||
INSERT INTO `app_pool` VALUES (104, '2026-07-02 12:17:51.501', '2026-07-10 14:26:47.817', 'bongo-cat.exe', 'bongo-cat.exe', 'BongoCat', 'bongo-cat', 8, '2026-07-10 14:26:47.816', 1406, 141);
|
||||
INSERT INTO `app_pool` VALUES (105, '2026-07-02 12:19:46.798', '2026-07-02 12:25:23.849', 'RvRvpnGui.exe', 'rvrvpngui.exe', 'Radmin VPN', 'rvrvpngui', 2, '2026-07-02 12:25:23.848', 209, 156);
|
||||
INSERT INTO `app_pool` VALUES (106, '2026-07-02 12:20:28.283', '2026-07-02 12:22:04.247', 'AvastBrowser.exe', 'avastbrowser.exe', 'Tab mới - Avast Secure Browser', 'avastbrowser', 2, '2026-07-02 12:22:04.246', 1648, 141);
|
||||
INSERT INTO `app_pool` VALUES (107, '2026-07-02 12:23:15.491', '2026-07-07 16:05:35.276', 'Notion.exe', 'notion.exe', 'Habit tracker ', 'notion', 7, '2026-07-07 16:05:35.275', 1576, 141);
|
||||
INSERT INTO `app_pool` VALUES (108, '2026-07-02 12:23:19.428', '2026-07-08 12:12:46.490', 'FxSound.exe', 'fxsound.exe', 'FxSound', 'fxsound', 6, '2026-07-08 12:12:46.489', 1432, 142);
|
||||
INSERT INTO `app_pool` VALUES (109, '2026-07-02 12:23:26.936', '2026-07-02 13:46:42.272', 'Obsidian.exe', 'obsidian.exe', 'Xem biểu đồ - Obsidian Vault - Obsidian 1.12.7', 'obsidian', 10, '2026-07-02 13:46:42.271', 1414, 141);
|
||||
INSERT INTO `app_pool` VALUES (110, '2026-07-02 12:23:45.465', '2026-07-08 14:54:18.853', 'app_mode_loader', 'app_mode_loader', 'app_mode_loader', 'app_mode_loader', 6, '2026-07-08 14:54:18.852', 1705, 83);
|
||||
INSERT INTO `app_pool` VALUES (111, '2026-07-02 12:29:34.095', '2026-07-02 12:29:34.095', 'Attack Shark Driver.exe', 'attack shark driver.exe', 'Quantum Mechanical Kit', 'attack shark driver', 1, '2026-07-02 12:29:34.094', 273, 156);
|
||||
INSERT INTO `app_pool` VALUES (112, '2026-07-02 12:30:09.476', '2026-07-03 14:16:55.336', 'wscript.exe', 'wscript.exe', 'Windows Script Host', 'wscript', 3, '2026-07-03 14:16:55.336', 1337, 81);
|
||||
INSERT INTO `app_pool` VALUES (113, '2026-07-02 12:30:32.139', '2026-07-08 13:58:31.681', 'citra-qt.exe', 'citra-qt.exe', 'Citra Nightly 2104', 'citra-qt', 4, '2026-07-08 13:58:31.680', 194, 156);
|
||||
INSERT INTO `app_pool` VALUES (114, '2026-07-02 12:32:07.676', '2026-07-13 07:49:09.781', 'Preview', 'preview', 'Preview', 'preview', 13, '2026-07-13 07:49:09.780', 1675, 75);
|
||||
INSERT INTO `app_pool` VALUES (115, '2026-07-02 12:32:35.230', '2026-07-03 12:31:01.545', 'Update.exe', 'update.exe', 'Installing...', 'update', 2, '2026-07-03 12:31:01.543', 239, 156);
|
||||
INSERT INTO `app_pool` VALUES (116, '2026-07-02 12:32:51.458', '2026-07-02 12:32:51.458', 'ClipOne', 'clipone', 'ClipOne', 'clipone', 1, '2026-07-02 12:32:51.457', 1488, 142);
|
||||
INSERT INTO `app_pool` VALUES (117, '2026-07-02 12:33:27.422', '2026-07-08 07:00:44.301', 'Xmind.exe', 'xmind.exe', 'Xmind', 'xmind', 12, '2026-07-08 07:00:44.300', 1330, 75);
|
||||
INSERT INTO `app_pool` VALUES (118, '2026-07-02 12:33:50.227', '2026-07-03 13:00:21.529', 'Claude Setup.exe', 'claude setup.exe', 'Claude Setup', 'claude setup', 3, '2026-07-03 13:00:21.528', 244, 156);
|
||||
INSERT INTO `app_pool` VALUES (119, '2026-07-02 12:35:50.441', '2026-07-02 15:19:55.467', 'steam.exe', 'steam.exe', 'Steam', 'steam', 3, '2026-07-02 15:19:55.466', 119, 49);
|
||||
INSERT INTO `app_pool` VALUES (120, '2026-07-02 12:36:05.531', '2026-07-02 12:36:05.531', 'lghub.exe', 'lghub.exe', 'G HUB', 'lghub', 1, '2026-07-02 12:36:05.530', 267, 156);
|
||||
INSERT INTO `app_pool` VALUES (121, '2026-07-02 12:36:23.215', '2026-07-02 14:37:18.592', 'UniKeyNT.exe', 'unikeynt.exe', 'Warning', 'unikeynt', 7, '2026-07-02 14:37:18.592', 167, 49);
|
||||
INSERT INTO `app_pool` VALUES (122, '2026-07-02 12:41:38.898', '2026-07-13 07:24:03.362', 'AsusMyASUS.exe', 'asusmyasus.exe', 'MyASUS', 'asusmyasus', 8, '2026-07-13 07:24:03.362', 1781, 84);
|
||||
INSERT INTO `app_pool` VALUES (123, '2026-07-02 12:41:41.896', '2026-07-08 15:33:36.817', 'GHelper.exe', 'ghelper.exe', 'G-Helper — ROG Zephyrus G14 GA403UM', 'ghelper', 5, '2026-07-08 15:33:36.816', 1489, 141);
|
||||
INSERT INTO `app_pool` VALUES (124, '2026-07-02 12:42:17.491', '2026-07-02 12:42:17.491', 'Lenovo Smart Meeting.exe', 'lenovo smart meeting.exe', 'Lenovo Smart Meeting', 'lenovo smart meeting', 1, '2026-07-02 12:42:17.490', 1529, 142);
|
||||
INSERT INTO `app_pool` VALUES (125, '2026-07-02 12:44:05.888', '2026-07-09 08:32:17.255', 'PowerToys.Peek.UI.exe', 'powertoys.peek.ui.exe', 'Peek', 'powertoys.peek.ui', 2, '2026-07-09 08:32:17.254', 595, 63);
|
||||
INSERT INTO `app_pool` VALUES (126, '2026-07-02 12:44:31.148', '2026-07-03 13:14:49.507', 'Obsidian-1.12.7.exe', 'obsidian-1.12.7.exe', 'Obsidian Setup ', 'obsidian-1.12.7', 6, '2026-07-03 13:14:49.506', 202, 49);
|
||||
INSERT INTO `app_pool` VALUES (127, '2026-07-02 12:48:03.446', '2026-07-09 08:46:22.494', 'NVIDIA App.exe', 'nvidia app.exe', 'NVIDIA', 'nvidia app', 2, '2026-07-09 08:46:22.494', 642, 144);
|
||||
INSERT INTO `app_pool` VALUES (128, '2026-07-02 12:56:28.881', '2026-07-13 07:39:00.729', 'VoiceAccess.exe', 'voiceaccess.exe', 'Voice access', 'voiceaccess', 6, '2026-07-13 07:39:00.728', 639, 63);
|
||||
INSERT INTO `app_pool` VALUES (129, '2026-07-02 12:56:37.848', '2026-07-06 10:46:09.052', 'rundll32.exe', 'rundll32.exe', 'Windows Security Alert', 'rundll32', 6, '2026-07-06 10:46:09.051', 308, 156);
|
||||
INSERT INTO `app_pool` VALUES (130, '2026-07-02 13:11:11.616', '2026-07-02 13:11:35.582', 'kitty', 'kitty', 'kitty', 'kitty', 2, '2026-07-02 13:11:35.580', 1492, 141);
|
||||
INSERT INTO `app_pool` VALUES (131, '2026-07-02 13:13:20.422', '2026-07-13 09:10:04.337', 'Garena.exe', 'garena.exe', 'Garena - Trò chơi', 'garena', 23, '2026-07-13 09:10:04.330', 1779, 82);
|
||||
INSERT INTO `app_pool` VALUES (132, '2026-07-02 13:16:44.576', '2026-07-13 07:34:27.451', 'raiav3', 'raiav3', 'raiav3', 'raiav3', 4, '2026-07-13 07:34:27.451', 289, 42);
|
||||
INSERT INTO `app_pool` VALUES (133, '2026-07-02 13:17:51.172', '2026-07-02 13:17:51.172', 'python-3.12.10-amd64.exe', 'python-3.12.10-amd64.exe', 'Python 3.12.10 (64-bit) Setup', 'python-3.12.10-amd64', 1, '2026-07-02 13:17:51.172', 1424, 141);
|
||||
INSERT INTO `app_pool` VALUES (134, '2026-07-02 13:17:51.581', '2026-07-02 13:17:51.581', 'WordNest.exe', 'wordnest.exe', 'WordNest – Học Tiếng Anh', 'wordnest', 1, '2026-07-02 13:17:51.581', 1455, 141);
|
||||
INSERT INTO `app_pool` VALUES (135, '2026-07-02 13:21:39.421', '2026-07-07 09:55:02.713', 'updatechecker.exe', 'updatechecker.exe', 'MiniTool Partition Wizard', 'updatechecker', 3, '2026-07-07 09:55:02.712', 1759, 82);
|
||||
INSERT INTO `app_pool` VALUES (136, '2026-07-02 13:25:19.865', '2026-07-02 13:25:19.865', 'FoxitPhantomPDF.exe', 'foxitphantompdf.exe', 'Foxit PhantomPDF', 'foxitphantompdf', 1, '2026-07-02 13:25:19.864', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (137, '2026-07-02 13:25:21.474', '2026-07-07 15:38:04.503', 'Weather', 'weather', 'Weather', 'weather', 4, '2026-07-07 15:38:04.502', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (138, '2026-07-02 13:25:22.866', '2026-07-02 13:25:22.866', 'memreduct.exe', 'memreduct.exe', 'Mem Reduct', 'memreduct', 1, '2026-07-02 13:25:22.866', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (139, '2026-07-02 13:27:40.007', '2026-07-13 07:35:54.657', 'gnome-clocks', 'gnome-clocks', 'gnome-clocks', 'gnome-clocks', 2, '2026-07-13 07:35:54.656', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (140, '2026-07-02 13:27:40.009', '2026-07-02 13:27:40.009', 'Socket Process', 'socket process', 'Socket Process', 'socket process', 1, '2026-07-02 13:27:40.008', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (141, '2026-07-02 13:27:40.012', '2026-07-02 13:27:40.012', 'Privileged Cont', 'privileged cont', 'Privileged Cont', 'privileged cont', 1, '2026-07-02 13:27:40.011', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (142, '2026-07-02 13:27:40.012', '2026-07-13 07:37:03.669', 'nautilus', 'nautilus', 'nautilus', 'nautilus', 12, '2026-07-13 07:37:03.668', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (143, '2026-07-02 13:27:40.012', '2026-07-02 13:27:40.012', 'Isolated Web Co', 'isolated web co', 'Isolated Web Co', 'isolated web co', 1, '2026-07-02 13:27:40.011', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (144, '2026-07-02 13:27:40.013', '2026-07-13 07:06:00.589', 'update-notifier', 'update-notifier', 'update-notifier', 'update-notifier', 7, '2026-07-13 07:06:00.588', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (145, '2026-07-02 13:27:40.068', '2026-07-02 13:27:40.068', 'Web Content', 'web content', 'Web Content', 'web content', 1, '2026-07-02 13:27:40.067', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (146, '2026-07-02 13:27:40.067', '2026-07-02 13:27:40.067', 'forkserver', 'forkserver', 'forkserver', 'forkserver', 1, '2026-07-02 13:27:40.066', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (147, '2026-07-02 13:27:40.068', '2026-07-02 13:27:40.068', 'glycin-svg', 'glycin-svg', 'glycin-svg', 'glycin-svg', 1, '2026-07-02 13:27:40.068', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (148, '2026-07-02 13:27:40.068', '2026-07-13 07:35:54.655', 'gnome-control-c', 'gnome-control-c', 'gnome-control-c', 'gnome-control-c', 2, '2026-07-13 07:35:54.655', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (149, '2026-07-02 13:27:40.069', '2026-07-02 13:27:40.069', 'WebKitNetworkPr', 'webkitnetworkpr', 'WebKitNetworkPr', 'webkitnetworkpr', 1, '2026-07-02 13:27:40.069', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (150, '2026-07-02 13:27:40.070', '2026-07-02 13:27:40.070', 'WebKitWebProces', 'webkitwebproces', 'WebKitWebProces', 'webkitwebproces', 1, '2026-07-02 13:27:40.069', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (151, '2026-07-02 13:27:40.098', '2026-07-02 13:27:40.098', 'Utility Process', 'utility process', 'Utility Process', 'utility process', 1, '2026-07-02 13:27:40.097', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (152, '2026-07-02 13:27:40.098', '2026-07-02 13:27:40.098', 'firefox', 'firefox', 'firefox', 'firefox', 1, '2026-07-02 13:27:40.097', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (153, '2026-07-02 13:27:40.112', '2026-07-02 13:27:40.112', 'WebExtensions', 'webextensions', 'WebExtensions', 'webextensions', 1, '2026-07-02 13:27:40.111', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (154, '2026-07-02 13:27:40.116', '2026-07-02 13:27:40.116', 'RDD Process', 'rdd process', 'RDD Process', 'rdd process', 1, '2026-07-02 13:27:40.116', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (155, '2026-07-02 13:27:42.965', '2026-07-02 13:27:42.965', 'zenity', 'zenity', 'zenity', 'zenity', 1, '2026-07-02 13:27:42.964', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (156, '2026-07-02 13:31:27.560', '2026-07-13 07:35:54.663', 'gnome-calculato', 'gnome-calculato', 'gnome-calculato', 'gnome-calculato', 2, '2026-07-13 07:35:54.662', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (157, '2026-07-02 13:31:36.551', '2026-07-02 13:31:36.551', 'snap-store', 'snap-store', 'snap-store', 'snap-store', 1, '2026-07-02 13:31:36.550', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (158, '2026-07-02 13:45:54.719', '2026-07-03 14:58:12.314', 'UTM', 'utm', 'UTM', 'utm', 2, '2026-07-03 14:58:12.313', 1488, 142);
|
||||
INSERT INTO `app_pool` VALUES (159, '2026-07-02 14:00:59.480', '2026-07-02 14:00:59.480', 'Magnify.exe', 'magnify.exe', 'Magnifier', 'magnify', 1, '2026-07-02 14:00:59.479', 1472, 141);
|
||||
INSERT INTO `app_pool` VALUES (160, '2026-07-02 14:00:59.484', '2026-07-02 14:00:59.484', 'ShellHost.exe', 'shellhost.exe', 'Magnifier updates', 'shellhost', 1, '2026-07-02 14:00:59.484', 1472, 141);
|
||||
INSERT INTO `app_pool` VALUES (161, '2026-07-02 14:27:09.431', '2026-07-02 14:27:09.431', 'UniKey.exe', 'unikey.exe', 'Warning', 'unikey', 1, '2026-07-02 14:27:09.430', 106, 49);
|
||||
INSERT INTO `app_pool` VALUES (162, '2026-07-02 14:28:06.922', '2026-07-02 15:28:14.861', 'osk.exe', 'osk.exe', 'On-Screen Keyboard', 'osk', 5, '2026-07-02 15:28:14.860', 1521, 72);
|
||||
INSERT INTO `app_pool` VALUES (163, '2026-07-02 14:31:03.603', '2026-07-02 14:31:03.603', 'BCUninstaller.exe', 'bcuninstaller.exe', 'Bulk Crap Uninstaller v6.1 x64', 'bcuninstaller', 1, '2026-07-02 14:31:03.602', 167, 49);
|
||||
INSERT INTO `app_pool` VALUES (164, '2026-07-02 14:31:15.059', '2026-07-10 08:01:35.619', 'javaw.exe', 'javaw.exe', 'TLauncher', 'javaw', 4, '2026-07-10 08:01:35.618', 1681, 74);
|
||||
INSERT INTO `app_pool` VALUES (165, '2026-07-02 14:32:02.171', '2026-07-07 14:23:04.390', 'Zoom.exe', 'zoom.exe', 'Zoom Workplace', 'zoom', 5, '2026-07-07 14:23:04.390', 1422, 72);
|
||||
INSERT INTO `app_pool` VALUES (166, '2026-07-02 14:32:31.635', '2026-07-06 12:17:56.118', 'netbeans64.exe', 'netbeans64.exe', 'Error', 'netbeans64', 3, '2026-07-06 12:17:56.117', 314, 49);
|
||||
INSERT INTO `app_pool` VALUES (167, '2026-07-02 14:43:47.299', '2026-07-02 14:43:47.299', 'Unknown', 'unknown', 'Welcome - Visual Studio Code', 'unknown', 1, '2026-07-02 14:43:47.298', 282, 49);
|
||||
INSERT INTO `app_pool` VALUES (168, '2026-07-02 14:51:18.381', '2026-07-09 08:43:00.944', 'FoxitReader.exe', 'foxitreader.exe', 'Foxit Reader', 'foxitreader', 4, '2026-07-09 08:43:00.943', 599, 144);
|
||||
INSERT INTO `app_pool` VALUES (169, '2026-07-02 14:53:47.294', '2026-07-08 14:36:24.571', 'wordpad.exe', 'wordpad.exe', 'prompy.txt - WordPad', 'wordpad', 2, '2026-07-08 14:36:24.571', 195, 49);
|
||||
INSERT INTO `app_pool` VALUES (170, '2026-07-02 14:57:17.928', '2026-07-07 14:00:41.333', 'Legion Arena.exe', 'legion arena.exe', 'Legion Arena', 'legion arena', 4, '2026-07-07 14:00:41.332', 117, 49);
|
||||
INSERT INTO `app_pool` VALUES (171, '2026-07-02 15:01:33.978', '2026-07-03 13:38:41.706', 'inno_updater.exe', 'inno_updater.exe', 'Visual Studio Code', 'inno_updater', 3, '2026-07-03 13:38:41.706', 170, 49);
|
||||
INSERT INTO `app_pool` VALUES (172, '2026-07-02 15:06:31.577', '2026-07-03 12:31:11.604', 'pgAdmin4.exe', 'pgadmin4.exe', 'pgadmin4', 'pgadmin4', 3, '2026-07-03 12:31:11.604', 216, 49);
|
||||
INSERT INTO `app_pool` VALUES (173, '2026-07-02 15:06:32.130', '2026-07-08 15:08:49.718', 'draw.io.exe', 'draw.io.exe', 'Flowchart Maker & Online Diagram Software', 'draw.io', 7, '2026-07-08 15:08:49.718', 117, 49);
|
||||
INSERT INTO `app_pool` VALUES (174, '2026-07-02 15:09:03.138', '2026-07-13 09:58:21.746', 'mspaint.exe', 'mspaint.exe', 'Untitled - Paint', 'mspaint', 7, '2026-07-13 09:58:21.745', 1718, 82);
|
||||
INSERT INTO `app_pool` VALUES (175, '2026-07-02 15:09:16.128', '2026-07-02 15:09:16.128', 'OemDrv.exe', 'oemdrv.exe', 'AULA F87 PRO', 'oemdrv', 1, '2026-07-02 15:09:16.127', 172, 49);
|
||||
INSERT INTO `app_pool` VALUES (176, '2026-07-02 15:13:07.671', '2026-07-02 15:13:07.671', 'navicat.exe', 'navicat.exe', 'allowed_email_domains @raia_v3_db (RAIA_V3) - Table - Navicat Premium', 'navicat', 1, '2026-07-02 15:13:07.670', 596, 63);
|
||||
INSERT INTO `app_pool` VALUES (177, '2026-07-02 15:13:07.704', '2026-07-09 14:50:52.629', 'DeskIn.exe', 'deskin.exe', 'DeskIn', 'deskin', 3, '2026-07-09 14:50:52.628', 1706, 83);
|
||||
INSERT INTO `app_pool` VALUES (178, '2026-07-02 15:50:52.211', '2026-07-02 15:50:52.211', 'gnome-text-edit', 'gnome-text-edit', 'gnome-text-edit', 'gnome-text-edit', 1, '2026-07-02 15:50:52.210', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (179, '2026-07-02 15:51:13.208', '2026-07-13 07:39:12.597', 'localsearch-ext', 'localsearch-ext', 'localsearch-ext', 'localsearch-ext', 20, '2026-07-13 07:39:12.596', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (180, '2026-07-02 15:52:15.689', '2026-07-08 08:14:36.188', 'Calculator', 'calculator', 'Calculator', 'calculator', 4, '2026-07-08 08:14:36.187', 1717, 84);
|
||||
INSERT INTO `app_pool` VALUES (181, '2026-07-02 15:53:12.243', '2026-07-02 15:53:12.243', '7zG.exe', '7zg.exe', 'Add to Archive', '7zg', 1, '2026-07-02 15:53:12.243', 159, 49);
|
||||
INSERT INTO `app_pool` VALUES (182, '2026-07-02 16:55:39.805', '2026-07-06 12:22:32.690', 'BingWallpaper.exe', 'bingwallpaper.exe', 'Bing Wallpaper', 'bingwallpaper', 2, '2026-07-06 12:22:32.689', 1592, 74);
|
||||
INSERT INTO `app_pool` VALUES (183, '2026-07-02 18:10:06.031', '2026-07-02 18:10:06.031', 'GitHubDesktop.exe', 'githubdesktop.exe', 'GitHub Desktop', 'githubdesktop', 1, '2026-07-02 18:10:06.030', 1525, 77);
|
||||
INSERT INTO `app_pool` VALUES (184, '2026-07-03 09:14:36.166', '2026-07-07 09:10:05.128', 'Canva.exe', 'canva.exe', 'Canva', 'canva', 3, '2026-07-07 09:10:05.127', 1698, 82);
|
||||
INSERT INTO `app_pool` VALUES (185, '2026-07-03 09:49:42.194', '2026-07-03 09:49:42.194', 'Deplao.exe', 'deplao.exe', 'Deplao', 'deplao', 1, '2026-07-03 09:49:42.193', 1759, 82);
|
||||
INSERT INTO `app_pool` VALUES (186, '2026-07-03 12:18:19.341', '2026-07-03 12:18:19.341', 'Codex Installer.exe', 'codex installer.exe', 'Microsoft Store', 'codex installer', 1, '2026-07-03 12:18:19.341', 195, 49);
|
||||
INSERT INTO `app_pool` VALUES (187, '2026-07-03 12:18:50.670', '2026-07-03 12:18:50.670', 'ATK HUB.exe', 'atk hub.exe', 'ATK HUB', 'atk hub', 1, '2026-07-03 12:18:50.669', 92, 156);
|
||||
INSERT INTO `app_pool` VALUES (188, '2026-07-03 12:20:20.891', '2026-07-03 12:20:20.891', 'com.apple.WebKit.Networking', 'com.apple.webkit.networking', 'com.apple.WebKit.Networking', 'com.apple.webkit.networking', 1, '2026-07-03 12:20:20.890', 254, 156);
|
||||
INSERT INTO `app_pool` VALUES (189, '2026-07-03 12:20:34.544', '2026-07-03 12:20:34.544', 'CMediaAudioControlPanel.exe', 'cmediaaudiocontrolpanel.exe', 'C-Media Audio Control Panel', 'cmediaaudiocontrolpanel', 1, '2026-07-03 12:20:34.544', 1337, 81);
|
||||
INSERT INTO `app_pool` VALUES (190, '2026-07-03 12:28:52.528', '2026-07-10 08:49:51.244', 'Lively.UI.WinUI.exe', 'lively.ui.winui.exe', 'Lively Wallpaper', 'lively.ui.winui', 7, '2026-07-10 08:49:51.242', 1391, 74);
|
||||
INSERT INTO `app_pool` VALUES (191, '2026-07-03 12:32:45.872', '2026-07-03 12:32:45.872', 'ChatGPT Installer.exe', 'chatgpt installer.exe', 'Microsoft Store', 'chatgpt installer', 1, '2026-07-03 12:32:45.871', 226, 156);
|
||||
INSERT INTO `app_pool` VALUES (192, '2026-07-03 12:35:42.121', '2026-07-03 14:21:26.749', 'fcitx5', 'fcitx5', 'fcitx5', 'fcitx5', 4, '2026-07-03 14:21:26.749', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (193, '2026-07-03 12:35:42.122', '2026-07-03 14:21:26.824', 'kdeconnectd', 'kdeconnectd', 'kdeconnectd', 'kdeconnectd', 4, '2026-07-03 14:21:26.823', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (194, '2026-07-03 12:35:42.151', '2026-07-03 14:21:26.744', 'wl-paste', 'wl-paste', 'wl-paste', 'wl-paste', 4, '2026-07-03 14:21:26.743', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (195, '2026-07-03 12:35:42.158', '2026-07-03 14:21:26.810', 'wl-copy', 'wl-copy', 'wl-copy', 'wl-copy', 3, '2026-07-03 14:21:26.809', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (196, '2026-07-03 12:35:42.164', '2026-07-03 14:21:26.745', 'Hyprland', 'hyprland', 'Hyprland', 'hyprland', 5, '2026-07-03 14:21:26.744', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (197, '2026-07-03 12:35:42.183', '2026-07-06 14:20:03.533', 'python3', 'python3', 'python3', 'python3', 5, '2026-07-06 14:20:03.532', 1624, 141);
|
||||
INSERT INTO `app_pool` VALUES (198, '2026-07-03 12:35:42.184', '2026-07-03 14:21:26.819', 'blueman-tray', 'blueman-tray', 'blueman-tray', 'blueman-tray', 3, '2026-07-03 14:21:26.819', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (199, '2026-07-03 12:35:42.187', '2026-07-03 14:21:26.798', 'nm-applet', 'nm-applet', 'nm-applet', 'nm-applet', 4, '2026-07-03 14:21:26.798', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (200, '2026-07-03 12:35:42.188', '2026-07-03 14:21:26.788', 'blueman-applet', 'blueman-applet', 'blueman-applet', 'blueman-applet', 4, '2026-07-03 14:21:26.787', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (201, '2026-07-03 12:35:42.205', '2026-07-03 14:21:26.812', 'swaync', 'swaync', 'swaync', 'swaync', 4, '2026-07-03 14:21:26.811', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (202, '2026-07-03 12:35:42.208', '2026-07-03 14:21:26.815', 'waybar', 'waybar', 'waybar', 'waybar', 5, '2026-07-03 14:21:26.814', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (203, '2026-07-03 12:38:37.501', '2026-07-03 12:38:37.501', 'brave', 'brave', 'brave', 'brave', 1, '2026-07-03 12:38:37.500', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (204, '2026-07-03 12:38:56.236', '2026-07-03 12:38:56.236', 'rofi', 'rofi', 'rofi', 'rofi', 1, '2026-07-03 12:38:56.235', 1624, 80);
|
||||
INSERT INTO `app_pool` VALUES (205, '2026-07-03 12:39:47.139', '2026-07-08 12:14:53.324', 'updater.exe', 'updater.exe', 'Trình cài đặt Google Chrome', 'updater', 4, '2026-07-08 12:14:53.324', 1432, 142);
|
||||
INSERT INTO `app_pool` VALUES (206, '2026-07-03 12:46:21.363', '2026-07-06 12:12:58.461', 'WeekToDo.exe', 'weektodo.exe', 'WeekToDo Planner', 'weektodo', 2, '2026-07-06 12:12:58.460', 106, 49);
|
||||
INSERT INTO `app_pool` VALUES (207, '2026-07-03 13:06:57.955', '2026-07-03 13:06:57.955', 'android-studio-quail1-patch2-windows.exe', 'android-studio-quail1-patch2-windows.exe', 'verifying installer: 67%', 'android-studio-quail1-patch2-windows', 1, '2026-07-03 13:06:57.954', 250, 156);
|
||||
INSERT INTO `app_pool` VALUES (208, '2026-07-03 13:11:41.447', '2026-07-08 12:37:31.068', 'DevinUserSetup-x64-3.3.18.tmp', 'devinusersetup-x64-3.3.18.tmp', 'Select Setup Language', 'devinusersetup-x64-3.3.18.tmp', 2, '2026-07-08 12:37:31.067', 285, 156);
|
||||
INSERT INTO `app_pool` VALUES (209, '2026-07-03 13:19:15.809', '2026-07-09 07:04:24.345', 'jucheck.exe', 'jucheck.exe', 'Java Update - Update Available', 'jucheck', 3, '2026-07-09 07:04:24.344', 692, 144);
|
||||
INSERT INTO `app_pool` VALUES (210, '2026-07-03 13:36:33.038', '2026-07-03 15:54:31.884', 'git-credential-manager.exe', 'git-credential-manager.exe', 'Connect to GitHub', 'git-credential-manager', 6, '2026-07-03 15:54:31.883', 164, 51);
|
||||
INSERT INTO `app_pool` VALUES (211, '2026-07-03 14:00:25.744', '2026-07-03 14:00:25.744', 'AutoClicker.exe', 'autoclicker.exe', 'OP Auto Clicker 4.0', 'autoclicker', 1, '2026-07-03 14:00:25.743', 202, 49);
|
||||
INSERT INTO `app_pool` VALUES (212, '2026-07-03 14:26:42.151', '2026-07-03 15:07:29.779', 'RadeonSoftware.exe', 'radeonsoftware.exe', 'AMD Software: Adrenalin Edition', 'radeonsoftware', 2, '2026-07-03 15:07:29.778', 1643, 142);
|
||||
INSERT INTO `app_pool` VALUES (213, '2026-07-03 14:58:08.785', '2026-07-03 14:59:53.780', 'AvastUI.exe', 'avastui.exe', 'Avast Free Antivirus', 'avastui', 6, '2026-07-03 14:59:53.779', 1474, 142);
|
||||
INSERT INTO `app_pool` VALUES (214, '2026-07-03 14:58:12.315', '2026-07-03 14:58:12.315', 'CrystalFetch', 'crystalfetch', 'CrystalFetch', 'crystalfetch', 1, '2026-07-03 14:58:12.314', 1488, 142);
|
||||
INSERT INTO `app_pool` VALUES (215, '2026-07-03 15:28:01.241', '2026-07-09 14:42:21.559', 'ZaloCall.exe', 'zalocall.exe', 'ZaloCall', 'zalocall', 3, '2026-07-09 14:42:21.558', 1618, 72);
|
||||
INSERT INTO `app_pool` VALUES (216, '2026-07-03 15:28:48.196', '2026-07-09 08:58:43.987', 'Kiro.exe', 'kiro.exe', 'workspace.json - Kiro', 'kiro', 3, '2026-07-09 08:58:43.986', 1565, 144);
|
||||
INSERT INTO `app_pool` VALUES (217, '2026-07-03 15:40:10.008', '2026-07-03 15:40:10.008', 'calibre.exe', 'calibre.exe', 'calibre — || Thư viện Calibre ||', 'calibre', 1, '2026-07-03 15:40:10.007', 84, 51);
|
||||
INSERT INTO `app_pool` VALUES (218, '2026-07-03 15:40:34.275', '2026-07-03 15:40:34.275', 'ebook-viewer.exe', 'ebook-viewer.exe', 'SRS [MD] — E-book viewer', 'ebook-viewer', 1, '2026-07-03 15:40:34.272', 84, 51);
|
||||
INSERT INTO `app_pool` VALUES (219, '2026-07-03 15:53:05.839', '2026-07-03 15:53:50.836', 'python.exe', 'python.exe', 'Tùy biến Entry - Duy', 'python', 2, '2026-07-03 15:53:50.835', 1669, 142);
|
||||
INSERT INTO `app_pool` VALUES (220, '2026-07-06 07:00:11.356', '2026-07-07 08:10:13.319', 'datagrip64.exe', 'datagrip64.exe', 'Global_DB', 'datagrip64', 2, '2026-07-07 08:10:13.318', 682, 63);
|
||||
INSERT INTO `app_pool` VALUES (221, '2026-07-06 07:04:26.348', '2026-07-06 07:04:26.348', 'ToolEditPDF-dev.exe', 'tooleditpdf-dev.exe', 'ToolEditPDF', 'tooleditpdf-dev', 1, '2026-07-06 07:04:26.347', 682, 63);
|
||||
INSERT INTO `app_pool` VALUES (222, '2026-07-06 07:15:21.438', '2026-07-13 08:17:19.344', 'ms-teams.exe', 'ms-teams.exe', 'Microsoft Teams', 'ms-teams', 7, '2026-07-13 08:17:19.341', 178, 75);
|
||||
INSERT INTO `app_pool` VALUES (223, '2026-07-06 07:18:39.049', '2026-07-07 07:23:36.475', 'krita.exe', 'krita.exe', 'homework(4,7).kra (250.2 MiB) - Krita', 'krita', 2, '2026-07-07 07:23:36.474', 689, 63);
|
||||
INSERT INTO `app_pool` VALUES (224, '2026-07-06 07:19:31.034', '2026-07-09 07:56:04.037', 'Photo Booth', 'photo booth', 'Photo Booth', 'photo booth', 5, '2026-07-09 07:56:04.036', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (225, '2026-07-06 07:46:45.450', '2026-07-06 07:46:45.450', 'open-design-0.13.0-win-x64-setup.exe', 'open-design-0.13.0-win-x64-setup.exe', 'Open Design Setup', 'open-design-0.13.0-win-x64-setup', 1, '2026-07-06 07:46:45.449', 637, 63);
|
||||
INSERT INTO `app_pool` VALUES (226, '2026-07-06 07:52:33.103', '2026-07-06 07:52:33.103', 'AppleMusic.exe', 'applemusic.exe', 'Apple Music', 'applemusic', 1, '2026-07-06 07:52:33.102', 1707, 84);
|
||||
INSERT INTO `app_pool` VALUES (227, '2026-07-06 07:54:42.691', '2026-07-06 07:54:42.691', 'dnmultiplayerex.exe', 'dnmultiplayerex.exe', 'LDMultiPlayer', 'dnmultiplayerex', 1, '2026-07-06 07:54:42.690', 1587, 75);
|
||||
INSERT INTO `app_pool` VALUES (228, '2026-07-06 08:12:08.183', '2026-07-09 07:11:56.158', 'Mail', 'mail', 'Mail', 'mail', 3, '2026-07-09 07:11:56.157', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (229, '2026-07-06 08:45:24.144', '2026-07-07 07:42:27.881', 'Bulk Repository Creator.exe', 'bulk repository creator.exe', 'Bulk Repository Creator', 'bulk repository creator', 2, '2026-07-07 07:42:27.879', 1627, 143);
|
||||
INSERT INTO `app_pool` VALUES (230, '2026-07-06 08:52:00.379', '2026-07-06 08:52:00.379', 'CanhCutTeam.exe', 'canhcutteam.exe', 'Cánh Cụt Team', 'canhcutteam', 1, '2026-07-06 08:52:00.377', 1563, 143);
|
||||
INSERT INTO `app_pool` VALUES (231, '2026-07-06 09:28:58.648', '2026-07-08 12:51:35.075', 'xampp-control.exe', 'xampp-control.exe', 'XAMPP Control Panel v3.3.0 [ Compiled: Apr 6th 2021 ]', 'xampp-control', 5, '2026-07-08 12:51:35.074', 1288, 72);
|
||||
INSERT INTO `app_pool` VALUES (232, '2026-07-06 12:10:12.433', '2026-07-06 12:18:04.993', 'Xmind', 'xmind', 'Xmind', 'xmind', 2, '2026-07-06 12:18:04.992', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (233, '2026-07-06 12:10:12.490', '2026-07-06 12:10:12.490', 'Skype for Business', 'skype for business', 'Skype for Business', 'skype for business', 1, '2026-07-06 12:10:12.489', 1760, 83);
|
||||
INSERT INTO `app_pool` VALUES (234, '2026-07-06 12:10:12.843', '2026-07-10 07:00:04.528', 'Microsoft Excel', 'microsoft excel', 'Microsoft Excel', 'microsoft excel', 4, '2026-07-10 07:00:04.527', 1608, 143);
|
||||
INSERT INTO `app_pool` VALUES (235, '2026-07-06 12:10:12.849', '2026-07-06 12:10:12.849', 'TV', 'tv', 'TV', 'tv', 1, '2026-07-06 12:10:12.846', 1760, 83);
|
||||
INSERT INTO `app_pool` VALUES (236, '2026-07-06 12:12:03.573', '2026-07-06 12:12:03.573', 'nvcplui.exe', 'nvcplui.exe', 'NVIDIA Control Panel', 'nvcplui', 1, '2026-07-06 12:12:03.573', 157, 49);
|
||||
INSERT INTO `app_pool` VALUES (237, '2026-07-06 12:12:12.326', '2026-07-06 12:15:28.921', 'gnome-session-b', 'gnome-session-b', 'gnome-session-b', 'gnome-session-b', 4, '2026-07-06 12:15:28.920', 82, 48);
|
||||
INSERT INTO `app_pool` VALUES (238, '2026-07-06 12:12:12.326', '2026-07-06 12:15:28.910', 'gnome-software', 'gnome-software', 'gnome-software', 'gnome-software', 4, '2026-07-06 12:15:28.909', 82, 48);
|
||||
INSERT INTO `app_pool` VALUES (239, '2026-07-06 12:12:23.954', '2026-07-07 12:10:06.306', 'QuickTime Player', 'quicktime player', 'QuickTime Player', 'quicktime player', 4, '2026-07-07 12:10:06.305', 1804, 83);
|
||||
INSERT INTO `app_pool` VALUES (240, '2026-07-06 12:12:23.964', '2026-07-10 07:03:12.479', 'Canva', 'canva', 'Canva', 'canva', 6, '2026-07-10 07:03:12.479', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (241, '2026-07-06 12:12:59.240', '2026-07-06 12:12:59.240', 'update-service.exe', 'update-service.exe', 'update-service', 'update-service', 1, '2026-07-06 12:12:59.239', 292, 48);
|
||||
INSERT INTO `app_pool` VALUES (242, '2026-07-06 12:13:33.242', '2026-07-06 12:17:31.852', 'tracker-extract', 'tracker-extract', 'tracker-extract', 'tracker-extract', 6, '2026-07-06 12:17:31.852', 82, 48);
|
||||
INSERT INTO `app_pool` VALUES (243, '2026-07-06 12:13:33.310', '2026-07-13 09:00:45.674', 'wireplumber', 'wireplumber', 'wireplumber', 'wireplumber', 22, '2026-07-13 09:00:45.672', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (244, '2026-07-06 12:18:05.001', '2026-07-07 16:04:37.743', 'Tips', 'tips', 'Tips', 'tips', 3, '2026-07-07 16:04:37.742', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (245, '2026-07-06 12:18:05.047', '2026-07-10 07:03:12.411', 'ChatGPT', 'chatgpt', 'ChatGPT', 'chatgpt', 5, '2026-07-10 07:03:12.410', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (246, '2026-07-06 12:18:05.050', '2026-07-10 07:03:12.164', 'Web App', 'web app', 'Web App', 'web app', 3, '2026-07-10 07:03:12.164', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (247, '2026-07-06 12:25:00.617', '2026-07-09 08:54:31.966', 'M365Copilot.exe', 'm365copilot.exe', 'Microsoft 365 Copilot', 'm365copilot', 3, '2026-07-09 08:54:31.965', 1656, 143);
|
||||
INSERT INTO `app_pool` VALUES (248, '2026-07-06 12:42:30.365', '2026-07-06 12:42:30.365', 'Archive Utility', 'archive utility', 'Archive Utility', 'archive utility', 1, '2026-07-06 12:42:30.364', 152, 48);
|
||||
INSERT INTO `app_pool` VALUES (249, '2026-07-06 12:47:36.723', '2026-07-06 12:47:36.723', 'Visual Paradigm.exe', 'visual paradigm.exe', 'Visual Paradigm', 'visual paradigm', 1, '2026-07-06 12:47:36.721', 247, 48);
|
||||
INSERT INTO `app_pool` VALUES (250, '2026-07-06 12:51:36.161', '2026-07-06 12:51:36.161', 'BackgroundTaskManagementAgent', 'backgroundtaskmanagementagent', 'BackgroundTaskManagementAgent', 'backgroundtaskmanagementagent', 1, '2026-07-06 12:51:36.160', 133, 48);
|
||||
INSERT INTO `app_pool` VALUES (251, '2026-07-06 12:56:14.030', '2026-07-06 13:29:08.141', 'ZaloSetup-26.6.20.exe', 'zalosetup-26.6.20.exe', 'Cài đặt Zalo ', 'zalosetup-26.6.20', 2, '2026-07-06 13:29:08.140', 150, 49);
|
||||
INSERT INTO `app_pool` VALUES (252, '2026-07-06 12:57:18.755', '2026-07-06 12:57:18.755', 'Chromium', 'chromium', 'Chromium', 'chromium', 1, '2026-07-06 12:57:18.754', 102, 48);
|
||||
INSERT INTO `app_pool` VALUES (253, '2026-07-06 13:03:05.047', '2026-07-09 08:01:36.150', 'Figma.exe', 'figma.exe', 'Learning platform - Figma', 'figma', 2, '2026-07-09 08:01:36.150', 681, 144);
|
||||
INSERT INTO `app_pool` VALUES (254, '2026-07-06 13:24:29.694', '2026-07-09 08:46:07.504', 'iCloudHome.exe', 'icloudhome.exe', 'iCloud', 'icloudhome', 2, '2026-07-09 08:46:07.503', 642, 144);
|
||||
INSERT INTO `app_pool` VALUES (255, '2026-07-06 13:52:17.224', '2026-07-06 13:52:17.224', 'VoiceMemos', 'voicememos', 'VoiceMemos', 'voicememos', 1, '2026-07-06 13:52:17.222', 111, 49);
|
||||
INSERT INTO `app_pool` VALUES (256, '2026-07-06 14:00:44.090', '2026-07-06 14:00:44.090', 'et.exe', 'et.exe', 'Loading application', 'et', 1, '2026-07-06 14:00:44.089', 188, 49);
|
||||
INSERT INTO `app_pool` VALUES (257, '2026-07-06 14:00:56.086', '2026-07-06 14:00:56.086', 'transerr.exe', 'transerr.exe', 'Send error report', 'transerr', 1, '2026-07-06 14:00:56.085', 188, 49);
|
||||
INSERT INTO `app_pool` VALUES (258, '2026-07-06 14:20:09.527', '2026-07-09 14:09:05.443', 'grim', 'grim', 'grim', 'grim', 119, '2026-07-09 14:09:05.443', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (259, '2026-07-06 14:20:13.575', '2026-07-06 14:20:13.575', 'MicrosoftWhiteboard.exe', 'microsoftwhiteboard.exe', 'Microsoft Whiteboard', 'microsoftwhiteboard', 1, '2026-07-06 14:20:13.574', 1556, 141);
|
||||
INSERT INTO `app_pool` VALUES (260, '2026-07-06 14:20:32.780', '2026-07-08 07:19:43.445', 'OmenCommandCenterBackground.exe', 'omencommandcenterbackground.exe', 'OMEN Background Process', 'omencommandcenterbackground', 2, '2026-07-08 07:19:43.444', 1378, 75);
|
||||
INSERT INTO `app_pool` VALUES (261, '2026-07-06 14:21:01.520', '2026-07-07 15:48:03.808', 'YourPhoneAppProxy.exe', 'yourphoneappproxy.exe', 'A25 của Đăng', 'yourphoneappproxy', 2, '2026-07-07 15:48:03.807', 1406, 141);
|
||||
INSERT INTO `app_pool` VALUES (262, '2026-07-06 14:27:06.322', '2026-07-06 14:27:06.322', 'Clock', 'clock', 'Clock', 'clock', 1, '2026-07-06 14:27:06.321', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (263, '2026-07-06 14:37:39.540', '2026-07-06 15:25:33.395', 'feh', 'feh', 'feh', 'feh', 2, '2026-07-06 15:25:33.394', 1624, 141);
|
||||
INSERT INTO `app_pool` VALUES (264, '2026-07-06 14:37:45.544', '2026-07-09 12:47:41.652', 'slurp', 'slurp', 'slurp', 'slurp', 7, '2026-07-09 12:47:41.651', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (265, '2026-07-06 15:24:20.930', '2026-07-07 08:16:54.832', 'HP.Omen.OmenCommandCenter.exe', 'hp.omen.omencommandcenter.exe', 'OMEN Gaming Hub', 'hp.omen.omencommandcenter', 2, '2026-07-07 08:16:54.831', 690, 63);
|
||||
INSERT INTO `app_pool` VALUES (266, '2026-07-06 16:15:12.426', '2026-07-07 16:03:03.396', 'obs', 'obs', 'obs', 'obs', 2, '2026-07-07 16:03:03.395', 1624, 141);
|
||||
INSERT INTO `app_pool` VALUES (267, '2026-07-07 07:00:15.719', '2026-07-07 07:00:15.719', 'jaxtinamobile', 'jaxtinamobile', 'jaxtinamobile', 'jaxtinamobile', 1, '2026-07-07 07:00:15.718', 303, 156);
|
||||
INSERT INTO `app_pool` VALUES (268, '2026-07-07 07:02:39.109', '2026-07-08 07:03:38.865', 'EADesktop.exe', 'eadesktop.exe', 'EA', 'eadesktop', 2, '2026-07-08 07:03:38.865', 1565, 144);
|
||||
INSERT INTO `app_pool` VALUES (269, '2026-07-07 07:03:06.229', '2026-07-13 07:59:21.454', 'PredatorSense.exe', 'predatorsense.exe', 'PredatorSense', 'predatorsense', 2, '2026-07-13 07:59:21.453', 586, 144);
|
||||
INSERT INTO `app_pool` VALUES (270, '2026-07-07 07:04:40.595', '2026-07-07 07:04:40.595', 'OpenVPNConnect.exe', 'openvpnconnect.exe', 'OpenVPN Connect', 'openvpnconnect', 1, '2026-07-07 07:04:40.594', 687, 63);
|
||||
INSERT INTO `app_pool` VALUES (271, '2026-07-07 07:19:23.971', '2026-07-07 07:19:23.971', 'Sniptool.exe', 'sniptool.exe', 'Sniptool 2.1', 'sniptool', 1, '2026-07-07 07:19:23.970', 643, 144);
|
||||
INSERT INTO `app_pool` VALUES (272, '2026-07-07 07:35:55.891', '2026-07-08 07:43:50.626', 'Miro.exe', 'miro.exe', 'Miro', 'miro', 3, '2026-07-08 07:43:50.624', 605, 63);
|
||||
INSERT INTO `app_pool` VALUES (273, '2026-07-07 08:09:26.349', '2026-07-10 08:05:31.149', 'video_conference_sdk', 'video_conference_sdk', 'video_conference_sdk', 'video_conference_sdk', 3, '2026-07-10 08:05:31.148', 640, 63);
|
||||
INSERT INTO `app_pool` VALUES (274, '2026-07-07 08:35:12.201', '2026-07-07 08:35:12.201', 'laragon-wamp.tmp', 'laragon-wamp.tmp', 'Select Setup Language', 'laragon-wamp.tmp', 1, '2026-07-07 08:35:12.200', 1659, 143);
|
||||
INSERT INTO `app_pool` VALUES (275, '2026-07-07 08:41:28.555', '2026-07-07 08:41:28.555', 'Lenovo Legion Toolkit.exe', 'lenovo legion toolkit.exe', 'Lenovo Legion Toolkit', 'lenovo legion toolkit', 1, '2026-07-07 08:41:28.554', 687, 63);
|
||||
INSERT INTO `app_pool` VALUES (276, '2026-07-07 08:59:25.954', '2026-07-07 09:00:07.949', 'DevinUserSetup-x64-3.4.22.tmp', 'devinusersetup-x64-3.4.22.tmp', 'Setup - Devin (User)', 'devinusersetup-x64-3.4.22.tmp', 2, '2026-07-07 09:00:07.948', 244, 156);
|
||||
INSERT INTO `app_pool` VALUES (277, '2026-07-07 09:47:22.092', '2026-07-07 09:47:22.092', 'Cloudflare WARP', 'cloudflare warp', 'Cloudflare WARP', 'cloudflare warp', 1, '2026-07-07 09:47:22.090', 640, 63);
|
||||
INSERT INTO `app_pool` VALUES (278, '2026-07-07 10:06:01.286', '2026-07-07 10:06:01.286', 'TreeSizeFree.exe', 'treesizefree.exe', 'TreeSize Free', 'treesizefree', 1, '2026-07-07 10:06:01.285', 1306, 74);
|
||||
INSERT INTO `app_pool` VALUES (279, '2026-07-07 10:17:46.599', '2026-07-07 10:17:46.599', 'MKTLogin.exe', 'mktlogin.exe', 'MKTLogin 2.1.2', 'mktlogin', 1, '2026-07-07 10:17:46.597', 621, 63);
|
||||
INSERT INTO `app_pool` VALUES (280, '2026-07-07 10:55:04.893', '2026-07-07 10:55:04.893', 'WinSCP.exe', 'winscp.exe', 'Documents – WinSCP', 'winscp', 1, '2026-07-07 10:55:04.892', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (281, '2026-07-07 12:14:01.078', '2026-07-10 14:34:05.169', 'akonadi_mailmer', 'akonadi_mailmer', 'akonadi_mailmer', 'akonadi_mailmer', 4, '2026-07-10 14:34:05.168', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (282, '2026-07-07 12:14:01.078', '2026-07-10 14:34:05.148', 'akonadi_mailfil', 'akonadi_mailfil', 'akonadi_mailfil', 'akonadi_mailfil', 4, '2026-07-10 14:34:05.147', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (283, '2026-07-07 12:14:01.083', '2026-07-10 14:34:05.098', 'akonadi_contact', 'akonadi_contact', 'akonadi_contact', 'akonadi_contact', 4, '2026-07-10 14:34:05.097', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (284, '2026-07-07 12:14:01.084', '2026-07-10 16:18:44.138', 'pulseaudio', 'pulseaudio', 'pulseaudio', 'pulseaudio', 1477, '2026-07-10 16:18:44.138', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (285, '2026-07-07 12:14:01.093', '2026-07-10 14:34:05.090', 'akonadi_indexin', 'akonadi_indexin', 'akonadi_indexin', 'akonadi_indexin', 4, '2026-07-10 14:34:05.089', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (286, '2026-07-07 12:14:01.131', '2026-07-10 14:34:05.144', 'akonadi_ical_re', 'akonadi_ical_re', 'akonadi_ical_re', 'akonadi_ical_re', 4, '2026-07-10 14:34:05.143', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (287, '2026-07-07 12:14:01.132', '2026-07-10 14:34:05.158', 'kalendarac', 'kalendarac', 'kalendarac', 'kalendarac', 4, '2026-07-10 14:34:05.158', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (288, '2026-07-07 12:14:01.140', '2026-07-10 14:34:05.157', 'akonadi_unified', 'akonadi_unified', 'akonadi_unified', 'akonadi_unified', 4, '2026-07-10 14:34:05.156', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (289, '2026-07-07 12:14:01.141', '2026-07-10 14:34:05.158', 'kgpg', 'kgpg', 'kgpg', 'kgpg', 4, '2026-07-10 14:34:05.157', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (290, '2026-07-07 12:14:01.142', '2026-07-10 14:34:05.154', 'akonadi_sendlat', 'akonadi_sendlat', 'akonadi_sendlat', 'akonadi_sendlat', 4, '2026-07-10 14:34:05.154', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (291, '2026-07-07 12:14:01.147', '2026-07-10 14:34:05.089', 'akonadi_migrati', 'akonadi_migrati', 'akonadi_migrati', 'akonadi_migrati', 4, '2026-07-10 14:34:05.088', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (292, '2026-07-07 12:14:01.147', '2026-07-10 14:34:05.149', 'akonadi_birthda', 'akonadi_birthda', 'akonadi_birthda', 'akonadi_birthda', 4, '2026-07-10 14:34:05.148', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (293, '2026-07-07 12:14:01.147', '2026-07-10 14:34:05.148', 'akonadi_archive', 'akonadi_archive', 'akonadi_archive', 'akonadi_archive', 4, '2026-07-10 14:34:05.147', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (294, '2026-07-07 12:14:01.150', '2026-07-10 14:34:05.154', 'akonadi_followu', 'akonadi_followu', 'akonadi_followu', 'akonadi_followu', 4, '2026-07-10 14:34:05.154', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (295, '2026-07-07 12:14:01.151', '2026-07-10 14:34:05.167', 'akonadi_maildis', 'akonadi_maildis', 'akonadi_maildis', 'akonadi_maildis', 4, '2026-07-10 14:34:05.166', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (296, '2026-07-07 12:14:01.152', '2026-07-10 16:02:06.154', 'plasma_waitforn', 'plasma_waitforn', 'plasma_waitforn', 'plasma_waitforn', 45, '2026-07-10 16:02:06.153', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (297, '2026-07-07 12:14:01.153', '2026-07-10 14:34:05.099', 'akonadi_newmail', 'akonadi_newmail', 'akonadi_newmail', 'akonadi_newmail', 4, '2026-07-10 14:34:05.098', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (298, '2026-07-07 12:14:01.158', '2026-07-10 14:34:05.146', 'ksecretd', 'ksecretd', 'ksecretd', 'ksecretd', 4, '2026-07-10 14:34:05.145', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (299, '2026-07-07 12:14:01.160', '2026-07-10 14:34:05.165', 'akonadi_maildir', 'akonadi_maildir', 'akonadi_maildir', 'akonadi_maildir', 4, '2026-07-10 14:34:05.164', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (300, '2026-07-07 12:14:01.163', '2026-07-10 14:34:05.161', 'akonadi_control', 'akonadi_control', 'akonadi_control', 'akonadi_control', 4, '2026-07-10 14:34:05.161', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (301, '2026-07-07 12:14:01.167', '2026-07-10 14:34:05.098', 'kclockd', 'kclockd', 'kclockd', 'kclockd', 4, '2026-07-10 14:34:05.098', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (302, '2026-07-07 12:14:01.169', '2026-07-10 14:34:05.089', 'akonadiserver', 'akonadiserver', 'akonadiserver', 'akonadiserver', 4, '2026-07-10 14:34:05.088', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (303, '2026-07-07 12:15:09.380', '2026-07-07 12:15:09.380', 'Discord', 'discord', 'Discord', 'discord', 1, '2026-07-07 12:15:09.379', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (304, '2026-07-07 12:29:43.138', '2026-07-07 12:29:43.138', 'drkonqi-coredum', 'drkonqi-coredum', 'drkonqi-coredum', 'drkonqi-coredum', 1, '2026-07-07 12:29:43.121', 1558, 141);
|
||||
INSERT INTO `app_pool` VALUES (305, '2026-07-07 12:37:03.080', '2026-07-07 12:37:03.080', 'Dictionary', 'dictionary', 'Dictionary', 'dictionary', 1, '2026-07-07 12:37:03.079', 1492, 141);
|
||||
INSERT INTO `app_pool` VALUES (306, '2026-07-07 12:39:18.135', '2026-07-07 15:17:08.687', 'Phone', 'phone', 'Phone', 'phone', 5, '2026-07-07 15:17:08.686', 1492, 141);
|
||||
INSERT INTO `app_pool` VALUES (307, '2026-07-07 13:18:30.134', '2026-07-07 13:18:30.134', 'RunCat 365.exe', 'runcat 365.exe', 'Microsoft .NET', 'runcat 365', 1, '2026-07-07 13:18:30.133', 251, 49);
|
||||
INSERT INTO `app_pool` VALUES (308, '2026-07-07 13:21:54.130', '2026-07-07 13:21:54.130', 'NoteBookFanControl.exe', 'notebookfancontrol.exe', 'NoteBook FanControl', 'notebookfancontrol', 1, '2026-07-07 13:21:54.129', 251, 49);
|
||||
INSERT INTO `app_pool` VALUES (309, '2026-07-07 13:50:24.368', '2026-07-07 13:50:24.368', 'certutil.exe', 'certutil.exe', 'Security Warning', 'certutil', 1, '2026-07-07 13:50:24.367', 1414, 141);
|
||||
INSERT INTO `app_pool` VALUES (310, '2026-07-07 13:53:00.622', '2026-07-07 13:53:00.622', 'Display Calibrator', 'display calibrator', 'Display Calibrator', 'display calibrator', 1, '2026-07-07 13:53:00.621', 1488, 142);
|
||||
INSERT INTO `app_pool` VALUES (311, '2026-07-07 14:24:38.223', '2026-07-10 14:20:09.967', 'Real.exe', 'real.exe', 'Real', 'real', 2, '2026-07-10 14:20:09.966', 1584, 142);
|
||||
INSERT INTO `app_pool` VALUES (312, '2026-07-07 14:35:32.221', '2026-07-07 14:35:32.221', 'wpsoffice', 'wpsoffice', 'wpsoffice', 'wpsoffice', 1, '2026-07-07 14:35:32.221', 1661, 142);
|
||||
INSERT INTO `app_pool` VALUES (313, '2026-07-07 14:58:04.779', '2026-07-07 14:58:04.779', 'OllamaSetup.tmp', 'ollamasetup.tmp', 'Setup - Ollama version 0.31.1', 'ollamasetup.tmp', 1, '2026-07-07 14:58:04.778', 1406, 141);
|
||||
INSERT INTO `app_pool` VALUES (314, '2026-07-07 15:13:13.468', '2026-07-07 15:13:13.468', 'dbeaver', 'dbeaver', 'dbeaver', 'dbeaver', 1, '2026-07-07 15:13:13.467', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (315, '2026-07-07 16:01:06.757', '2026-07-07 16:01:06.757', 'CapCut.exe', 'capcut.exe', 'CapCut', 'capcut', 1, '2026-07-07 16:01:06.756', 1669, 142);
|
||||
INSERT INTO `app_pool` VALUES (316, '2026-07-08 07:05:20.504', '2026-07-13 08:04:19.520', 'vlc.exe', 'vlc.exe', 'v2.mp4 - VLC media player', 'vlc', 2, '2026-07-13 08:04:19.519', 621, 63);
|
||||
INSERT INTO `app_pool` VALUES (317, '2026-07-08 07:05:34.817', '2026-07-09 07:06:03.285', 'goslynk-launcher.exe', 'goslynk-launcher.exe', 'Goslynk Launcher', 'goslynk-launcher', 2, '2026-07-09 07:06:03.285', 620, 63);
|
||||
INSERT INTO `app_pool` VALUES (318, '2026-07-08 08:07:00.960', '2026-07-08 08:07:00.960', 'HWMonitor.exe', 'hwmonitor.exe', 'HWMonitor', 'hwmonitor', 1, '2026-07-08 08:07:00.959', 625, 63);
|
||||
INSERT INTO `app_pool` VALUES (319, '2026-07-08 09:15:35.015', '2026-07-13 09:38:30.534', 'ZaloCap.exe', 'zalocap.exe', 'ZaloCap', 'zalocap', 2, '2026-07-13 09:38:30.534', 1751, 82);
|
||||
INSERT INTO `app_pool` VALUES (320, '2026-07-08 09:32:11.010', '2026-07-08 09:32:11.010', 'OpenWith.exe', 'openwith.exe', 'Pick an app', 'openwith', 1, '2026-07-08 09:32:11.009', 611, 144);
|
||||
INSERT INTO `app_pool` VALUES (321, '2026-07-08 10:37:02.860', '2026-07-10 08:02:34.655', 'Setup.exe', 'setup.exe', 'Setup Wizard', 'setup', 5, '2026-07-10 08:02:34.655', 618, 63);
|
||||
INSERT INTO `app_pool` VALUES (322, '2026-07-08 12:06:56.296', '2026-07-10 14:26:55.413', 'ClassIn.exe', 'classin.exe', 'ClassIn', 'classin', 2, '2026-07-10 14:26:55.412', 1556, 141);
|
||||
INSERT INTO `app_pool` VALUES (323, '2026-07-08 12:39:03.393', '2026-07-08 12:42:06.383', 'Devin.exe', 'devin.exe', 'Devin', 'devin', 3, '2026-07-08 12:42:06.383', 244, 156);
|
||||
INSERT INTO `app_pool` VALUES (324, '2026-07-08 12:43:28.272', '2026-07-08 12:43:28.272', '7zFM.exe', '7zfm.exe', 'C:\\Users\\D365\\Downloads\\elearning_base-main.zip\\', '7zfm', 1, '2026-07-08 12:43:28.270', 162, 156);
|
||||
INSERT INTO `app_pool` VALUES (325, '2026-07-08 12:49:06.961', '2026-07-08 12:49:27.952', 'LiveCaptions.exe', 'livecaptions.exe', 'Live Captions', 'livecaptions', 2, '2026-07-08 12:49:27.951', 1812, 142);
|
||||
INSERT INTO `app_pool` VALUES (326, '2026-07-08 14:27:39.788', '2026-07-08 14:27:39.788', 'main.bin', 'main.bin', 'main.bin', 'main.bin', 1, '2026-07-08 14:27:39.788', 1624, 141);
|
||||
INSERT INTO `app_pool` VALUES (327, '2026-07-08 14:36:19.885', '2026-07-08 16:30:42.609', 'CocCoc', 'coccoc', 'CocCoc', 'coccoc', 5, '2026-07-08 16:30:42.608', 1705, 83);
|
||||
INSERT INTO `app_pool` VALUES (328, '2026-07-08 14:47:40.660', '2026-07-08 14:48:13.668', 'kdrmain.exe', 'kdrmain.exe', 'Document Repair', 'kdrmain', 2, '2026-07-08 14:48:13.667', 117, 49);
|
||||
INSERT INTO `app_pool` VALUES (329, '2026-07-08 14:55:06.933', '2026-07-08 14:55:06.933', 'CloudNotifications.exe', 'cloudnotifications.exe', 'Low disk space', 'cloudnotifications', 1, '2026-07-08 14:55:06.932', 1666, 141);
|
||||
INSERT INTO `app_pool` VALUES (330, '2026-07-08 15:00:20.590', '2026-07-13 09:00:03.633', 'mysql-workbench', 'mysql-workbench', 'mysql-workbench', 'mysql-workbench', 5, '2026-07-13 09:00:03.632', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (331, '2026-07-08 15:10:11.300', '2026-07-08 15:10:11.300', 'MSPCManager.exe', 'mspcmanager.exe', 'Microsoft PC Manager', 'mspcmanager', 1, '2026-07-08 15:10:11.299', 1424, 141);
|
||||
INSERT INTO `app_pool` VALUES (332, '2026-07-08 15:50:37.798', '2026-07-08 15:50:37.798', 'ollama app.exe', 'ollama app.exe', 'Ollama', 'ollama app', 1, '2026-07-08 15:50:37.797', 1406, 141);
|
||||
INSERT INTO `app_pool` VALUES (333, '2026-07-09 07:09:09.051', '2026-07-09 07:09:54.268', 'XPPenTablet.exe', 'xppentablet.exe', 'XPPentablet', 'xppentablet', 3, '2026-07-09 07:09:54.267', 689, 63);
|
||||
INSERT INTO `app_pool` VALUES (334, '2026-07-09 07:25:34.911', '2026-07-09 07:25:34.911', 'eqMac', 'eqmac', 'eqMac', 'eqmac', 1, '2026-07-09 07:25:34.910', 640, 63);
|
||||
INSERT INTO `app_pool` VALUES (335, '2026-07-09 07:31:33.290', '2026-07-09 07:31:33.290', 'LinkedIn.exe', 'linkedin.exe', 'LinkedIn', 'linkedin', 1, '2026-07-09 07:31:33.289', 682, 63);
|
||||
INSERT INTO `app_pool` VALUES (336, '2026-07-09 07:41:35.961', '2026-07-09 07:41:35.961', 'FolderPainter.exe', 'folderpainter.exe', 'Folder Painter v1.3', 'folderpainter', 1, '2026-07-09 07:41:35.960', 625, 63);
|
||||
INSERT INTO `app_pool` VALUES (337, '2026-07-09 08:06:15.949', '2026-07-09 09:00:48.955', 'node.exe', 'node.exe', 'C:\\Program Files\\nodejs\\node.exe', 'node', 2, '2026-07-09 09:00:48.955', 599, 144);
|
||||
INSERT INTO `app_pool` VALUES (338, '2026-07-09 08:45:40.538', '2026-07-09 08:45:40.538', 'SamsungMagician.exe', 'samsungmagician.exe', 'Samsung Magician', 'samsungmagician', 1, '2026-07-09 08:45:40.538', 642, 144);
|
||||
INSERT INTO `app_pool` VALUES (339, '2026-07-09 08:47:40.202', '2026-07-09 08:49:46.265', 'wmplayer.exe', 'wmplayer.exe', 'Windows Media Player', 'wmplayer', 4, '2026-07-09 08:49:46.264', 617, 144);
|
||||
INSERT INTO `app_pool` VALUES (340, '2026-07-09 08:48:08.263', '2026-07-09 08:48:08.263', 'dnplayer.exe', 'dnplayer.exe', 'LDPlayer', 'dnplayer', 1, '2026-07-09 08:48:08.262', 1691, 143);
|
||||
INSERT INTO `app_pool` VALUES (341, '2026-07-09 09:13:55.144', '2026-07-09 09:13:55.144', 'hubflow', 'hubflow', 'hubflow', 'hubflow', 1, '2026-07-09 09:13:55.143', 592, 63);
|
||||
INSERT INTO `app_pool` VALUES (342, '2026-07-09 12:39:15.870', '2026-07-09 12:39:15.870', 'Unzip - RAR ZIP 7Z Unarchiver', 'unzip - rar zip 7z unarchiver', 'Unzip - RAR ZIP 7Z Unarchiver', 'unzip - rar zip 7z unarchiver', 1, '2026-07-09 12:39:15.869', 303, 156);
|
||||
INSERT INTO `app_pool` VALUES (343, '2026-07-09 12:41:05.290', '2026-07-09 12:41:05.290', 'java.exe', 'java.exe', 'Update', 'java', 1, '2026-07-09 12:41:05.290', 1583, 141);
|
||||
INSERT INTO `app_pool` VALUES (344, '2026-07-09 13:06:40.850', '2026-07-09 13:06:40.850', 'IDLE', 'idle', 'IDLE', 'idle', 1, '2026-07-09 13:06:40.849', 1333, 141);
|
||||
INSERT INTO `app_pool` VALUES (345, '2026-07-10 07:03:12.404', '2026-07-10 07:03:12.404', 'TeraBox', 'terabox', 'TeraBox', 'terabox', 1, '2026-07-10 07:03:12.403', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (346, '2026-07-10 07:03:12.414', '2026-07-10 07:03:12.414', 'Freeform', 'freeform', 'Freeform', 'freeform', 1, '2026-07-10 07:03:12.413', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (347, '2026-07-10 07:03:50.421', '2026-07-10 07:03:50.421', 'WinAppHelper', 'winapphelper', 'WinAppHelper', 'winapphelper', 1, '2026-07-10 07:03:50.420', 1613, 74);
|
||||
INSERT INTO `app_pool` VALUES (348, '2026-07-10 07:18:54.611', '2026-07-10 07:18:54.611', 'FaceTime', 'facetime', 'FaceTime', 'facetime', 1, '2026-07-10 07:18:54.611', 1717, 84);
|
||||
INSERT INTO `app_pool` VALUES (349, '2026-07-10 07:29:48.463', '2026-07-10 07:29:48.463', 'MicrosoftSecurityApp.exe', 'microsoftsecurityapp.exe', 'Microsoft Defender', 'microsoftsecurityapp', 1, '2026-07-10 07:29:48.462', 1355, 74);
|
||||
INSERT INTO `app_pool` VALUES (350, '2026-07-10 07:31:36.463', '2026-07-11 07:43:06.398', 'ChatGPT Classic.exe', 'chatgpt classic.exe', 'ChatGPT Classic', 'chatgpt classic', 6, '2026-07-11 07:43:06.397', 1384, 73);
|
||||
INSERT INTO `app_pool` VALUES (351, '2026-07-10 07:37:11.844', '2026-07-10 07:37:11.844', 'NovelDocumentSplitter-dev.exe', 'noveldocumentsplitter-dev.exe', 'Novel Document Splitter', 'noveldocumentsplitter-dev', 1, '2026-07-10 07:37:11.843', 682, 63);
|
||||
INSERT INTO `app_pool` VALUES (352, '2026-07-10 07:48:10.552', '2026-07-10 07:48:10.552', 'Open Design.exe', 'open design.exe', 'Open Design', 'open design', 1, '2026-07-10 07:48:10.551', 637, 63);
|
||||
INSERT INTO `app_pool` VALUES (353, '2026-07-10 07:55:59.466', '2026-07-10 07:55:59.466', 'SMAILWOLF-M3.exe', 'smailwolf-m3.exe', 'SMAILWOLF M3', 'smailwolf-m3', 1, '2026-07-10 07:55:59.465', 174, 74);
|
||||
INSERT INTO `app_pool` VALUES (354, '2026-07-10 07:56:02.470', '2026-07-10 07:56:02.470', 'MEG381_KC.tmp', 'meg381_kc.tmp', 'Setup', 'meg381_kc.tmp', 1, '2026-07-10 07:56:02.469', 174, 74);
|
||||
INSERT INTO `app_pool` VALUES (355, '2026-07-10 08:01:11.683', '2026-07-10 08:01:11.683', 'gamingservicesui.exe', 'gamingservicesui.exe', 'Minecraft Launcher ', 'gamingservicesui', 1, '2026-07-10 08:01:11.682', 174, 74);
|
||||
INSERT INTO `app_pool` VALUES (356, '2026-07-10 08:01:35.695', '2026-07-10 08:01:35.695', 'Minecraft.exe', 'minecraft.exe', 'Minecraft Launcher', 'minecraft', 1, '2026-07-10 08:01:35.694', 174, 74);
|
||||
INSERT INTO `app_pool` VALUES (357, '2026-07-10 08:01:38.680', '2026-07-10 08:01:38.680', 'prismlauncher.exe', 'prismlauncher.exe', 'Prism Launcher 10.0.5', 'prismlauncher', 1, '2026-07-10 08:01:38.679', 174, 74);
|
||||
INSERT INTO `app_pool` VALUES (358, '2026-07-10 08:05:31.146', '2026-07-10 08:05:31.146', 'IsExtensionEnabled', 'isextensionenabled', 'IsExtensionEnabled', 'isextensionenabled', 1, '2026-07-10 08:05:31.145', 640, 63);
|
||||
INSERT INTO `app_pool` VALUES (359, '2026-07-10 08:51:56.595', '2026-07-10 08:51:56.595', 'aura-wallpaper-editor.exe', 'aura-wallpaper-editor.exe', 'Aura Wallpaper Creator', 'aura-wallpaper-editor', 1, '2026-07-10 08:51:56.594', 1306, 74);
|
||||
INSERT INTO `app_pool` VALUES (360, '2026-07-10 08:57:23.585', '2026-07-10 08:57:23.585', 'colorcpl.exe', 'colorcpl.exe', 'Color Management', 'colorcpl', 1, '2026-07-10 08:57:23.584', 1306, 74);
|
||||
INSERT INTO `app_pool` VALUES (361, '2026-07-10 14:20:10.730', '2026-07-10 14:20:10.730', 'ClassIn', 'classin', 'ClassIn', 'classin', 1, '2026-07-10 14:20:10.729', 1661, 142);
|
||||
INSERT INTO `app_pool` VALUES (362, '2026-07-10 15:35:14.293', '2026-07-10 15:36:15.654', 'mpc-hc64.exe', 'mpc-hc64.exe', 'Quay màn hình 2026-07-10 005108.mp4', 'mpc-hc64', 2, '2026-07-10 15:36:15.652', 1474, 142);
|
||||
INSERT INTO `app_pool` VALUES (363, '2026-07-10 15:51:25.583', '2026-07-10 15:51:25.583', 'launcher_main.exe', 'launcher_main.exe', 'Wuthering Waves', 'launcher_main', 1, '2026-07-10 15:51:25.582', 1542, 142);
|
||||
INSERT INTO `app_pool` VALUES (364, '2026-07-10 16:03:00.515', '2026-07-10 16:03:00.515', 'Breach.exe', 'breach.exe', 'Into the Breach', 'breach', 1, '2026-07-10 16:03:00.514', 1557, 142);
|
||||
INSERT INTO `app_pool` VALUES (365, '2026-07-11 07:09:32.221', '2026-07-11 07:09:32.221', 'rsAppUI.exe', 'rsappui.exe', 'RAV Endpoint Protection notification', 'rsappui', 1, '2026-07-11 07:09:32.220', 1619, 77);
|
||||
INSERT INTO `app_pool` VALUES (366, '2026-07-13 07:06:00.569', '2026-07-13 07:06:00.569', 'caffeine-indica', 'caffeine-indica', 'caffeine-indica', 'caffeine-indica', 1, '2026-07-13 07:06:00.568', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (367, '2026-07-13 07:06:00.571', '2026-07-13 07:06:00.571', 'xprop', 'xprop', 'xprop', 'xprop', 1, '2026-07-13 07:06:00.570', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (368, '2026-07-13 07:06:00.580', '2026-07-13 07:06:00.580', 'caffeine', 'caffeine', 'caffeine', 'caffeine', 1, '2026-07-13 07:06:00.579', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (369, '2026-07-13 07:35:54.662', '2026-07-13 07:35:54.662', 'gnome-character', 'gnome-character', 'gnome-character', 'gnome-character', 1, '2026-07-13 07:35:54.661', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (370, '2026-07-13 07:36:45.661', '2026-07-13 07:36:45.661', 'apport-gtk', 'apport-gtk', 'apport-gtk', 'apport-gtk', 1, '2026-07-13 07:36:45.660', 1655, 143);
|
||||
INSERT INTO `app_pool` VALUES (371, '2026-07-13 07:48:39.921', '2026-07-13 09:17:21.762', 'Opera', 'opera', 'Opera', 'opera', 4, '2026-07-13 09:17:21.761', 644, 144);
|
||||
INSERT INTO `app_pool` VALUES (372, '2026-07-13 08:03:42.817', '2026-07-13 08:09:40.189', 'Python', 'python', 'Python', 'python', 2, '2026-07-13 08:09:40.188', 176, 77);
|
||||
INSERT INTO `app_pool` VALUES (373, '2026-07-13 08:07:37.794', '2026-07-13 08:07:37.794', 'Reminders', 'reminders', 'Reminders', 'reminders', 1, '2026-07-13 08:07:37.791', 1717, 84);
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -46,13 +46,13 @@ cd client
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
```
|
||||
Sau khi build xong, file thực thi sẽ nằm tại: `client/build/bin/simple_care_v1.0`
|
||||
Sau khi build xong, file thực thi sẽ nằm tại: `client/build/bin/simple_care_v1.3`
|
||||
|
||||
---
|
||||
|
||||
## 2. Cho Người dùng cuối (End-user - Chỉ chạy ứng dụng)
|
||||
|
||||
Người dùng cuối **không cần** cài đặt Go, Node hay trình biên dịch C. Họ chỉ cần tệp thực thi `simple_care_v1.0` và cài đặt các thư viện đồ họa cơ bản của hệ thống:
|
||||
Người dùng cuối **không cần** cài đặt Go, Node hay trình biên dịch C. Họ chỉ cần tệp thực thi `simple_care_v1.3` và cài đặt các thư viện đồ họa cơ bản của hệ thống:
|
||||
|
||||
* **Ubuntu / Debian / Linux Mint:**
|
||||
```bash
|
||||
@@ -73,8 +73,8 @@ Người dùng cuối **không cần** cài đặt Go, Node hay trình biên d
|
||||
### Lệnh chạy ứng dụng:
|
||||
Cấp quyền chạy cho file và khởi chạy:
|
||||
```bash
|
||||
chmod +x simple_care_v1.0
|
||||
./simple_care_v1.0
|
||||
chmod +x simple_care_v1.3
|
||||
./simple_care_v1.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
438
client/app.go
438
client/app.go
@@ -8,14 +8,15 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -154,6 +155,7 @@ type App struct {
|
||||
wifiRejected bool
|
||||
quitDialogShown bool
|
||||
monitoringTornDown bool
|
||||
localBrowserActive bool // trình duyệt local — chỉ cho phép localhost
|
||||
chatUnread int
|
||||
replyStaffID uint
|
||||
fetchAppsMu sync.Mutex
|
||||
@@ -276,6 +278,9 @@ func (a *App) startup(ctx context.Context) {
|
||||
|
||||
// Khởi chạy vòng lặp đọc local storage của trang Rikkei Portal khi chưa đăng nhập
|
||||
go a.authStorageScanner()
|
||||
|
||||
// Chặn thoát localhost khi đang dùng trình duyệt local
|
||||
go a.localBrowserGuardLoop()
|
||||
}
|
||||
|
||||
func (a *App) handleGuardViolation(kind, reason string) {
|
||||
@@ -286,27 +291,56 @@ func (a *App) handleGuardViolation(kind, reason string) {
|
||||
a.showQuitDialog("Vi phạm giám sát", reason)
|
||||
}
|
||||
|
||||
// HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm.
|
||||
// Trả về false để cho phép đóng sau khi đã báo cáo.
|
||||
// HandleBeforeClose — SV bấm X / Alt+F4: luôn hỏi xác nhận.
|
||||
// Có → thoát + lưu vết vi phạm (nếu đang giám sát exam/learning). Không → hủy đóng.
|
||||
func (a *App) HandleBeforeClose() (prevent bool) {
|
||||
if !a.CheckLoginStatus() || !a.isMonitoringActive() {
|
||||
a.clearRunLock()
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
mode := a.dashboard.MonitorMode
|
||||
loggedIn := a.student != nil
|
||||
a.mu.Unlock()
|
||||
if mode == "" || mode == "not_configured" {
|
||||
a.clearRunLock()
|
||||
return false
|
||||
|
||||
message := "Bạn có chắc muốn thoát Simple Care không?"
|
||||
if loggedIn && a.isMonitoringActive() && shouldRecordViolation(mode) {
|
||||
message = "Bạn có chắc chắn muốn thoát ứng dụng không?\n\nLưu ý: Thoát khi đang trong giờ thi/học sẽ được ghi nhận là VI PHẠM."
|
||||
}
|
||||
|
||||
a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")")
|
||||
selection, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
|
||||
Type: runtime.QuestionDialog,
|
||||
Title: "Xác nhận thoát",
|
||||
Message: message,
|
||||
Buttons: []string{"Có, thoát ứng dụng", "Không, tiếp tục"},
|
||||
DefaultButton: "Không, tiếp tục",
|
||||
CancelButton: "Không, tiếp tục",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[CLOSE] MessageDialog error: %v — hủy thoát để an toàn", err)
|
||||
return true
|
||||
}
|
||||
|
||||
if !isConfirmQuitSelection(selection) {
|
||||
return true // Không / Cancel → ở lại
|
||||
}
|
||||
|
||||
// Có → gửi vi phạm lên server rồi mới thoát
|
||||
if loggedIn && a.isMonitoringActive() && shouldRecordViolation(mode) {
|
||||
a.reportViolation("app_closed", "Sinh viên xác nhận tắt ứng dụng khi đang giám sát ("+mode+")")
|
||||
a.tearDownBeforeQuit()
|
||||
}
|
||||
a.clearRunLock()
|
||||
log.Printf("[CLOSE] User confirmed quit (mode=%s, loggedIn=%v)", mode, loggedIn)
|
||||
return false
|
||||
}
|
||||
|
||||
func isConfirmQuitSelection(selection string) bool {
|
||||
s := strings.TrimSpace(strings.ToLower(selection))
|
||||
switch s {
|
||||
case "có, thoát ứng dụng", "co, thoat ung dung", "yes", "ok", "có", "co":
|
||||
return true
|
||||
}
|
||||
// Windows đôi khi trả về đúng nhãn nút đã truyền
|
||||
return strings.Contains(s, "thoát") || strings.Contains(s, "thoat") || s == "yes"
|
||||
}
|
||||
|
||||
// tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối).
|
||||
func (a *App) tearDownBeforeQuit() {
|
||||
a.mu.Lock()
|
||||
@@ -357,22 +391,41 @@ type pendingViolation struct {
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"`
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
}
|
||||
|
||||
// shouldRecordViolation — chỉ lưu vi phạm khi đang thi hoặc đang học (trong giờ).
|
||||
func shouldRecordViolation(monitorMode string) bool {
|
||||
switch strings.TrimSpace(monitorMode) {
|
||||
case "exam", "learning":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) markRunLock() {
|
||||
a.mu.Lock()
|
||||
studentID := int64(0)
|
||||
mode := a.dashboard.MonitorMode
|
||||
classID := a.dashboard.ClassRkID
|
||||
examRoomID := uint(0)
|
||||
if a.dashboard.Exam != nil {
|
||||
examRoomID = a.dashboard.Exam.ExamRoomID
|
||||
}
|
||||
if a.student != nil {
|
||||
studentID = a.student.StudentID
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if studentID <= 0 || mode == "" || mode == "not_configured" {
|
||||
if studentID <= 0 || !shouldRecordViolation(mode) {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"studentRkId": studentID,
|
||||
"monitorMode": mode,
|
||||
"classRkId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"startedAt": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
_ = os.WriteFile(a.runLockPath, payload, 0644)
|
||||
@@ -392,6 +445,8 @@ func (a *App) detectUncleanShutdown() {
|
||||
var meta struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &meta)
|
||||
@@ -402,7 +457,7 @@ func (a *App) detectUncleanShutdown() {
|
||||
}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
if meta.StudentRkID <= 0 {
|
||||
if meta.StudentRkID <= 0 || !shouldRecordViolation(meta.MonitorMode) {
|
||||
return
|
||||
}
|
||||
reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát"
|
||||
@@ -415,6 +470,8 @@ func (a *App) detectUncleanShutdown() {
|
||||
MonitorMode: meta.MonitorMode,
|
||||
ClientAt: time.Now().Format(time.RFC3339),
|
||||
StudentRkID: meta.StudentRkID,
|
||||
ClassRkID: meta.ClassRkID,
|
||||
ExamRoomID: meta.ExamRoomID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -457,8 +514,8 @@ func (a *App) flushPendingViolations() {
|
||||
}
|
||||
|
||||
func (a *App) postViolation(v pendingViolation) bool {
|
||||
if v.StudentRkID <= 0 {
|
||||
return true
|
||||
if v.StudentRkID <= 0 || !shouldRecordViolation(v.MonitorMode) {
|
||||
return true // bỏ qua — không thi / không học thì không lưu
|
||||
}
|
||||
payload := map[string]any{
|
||||
"studentRkId": v.StudentRkID,
|
||||
@@ -466,9 +523,11 @@ func (a *App) postViolation(v pendingViolation) bool {
|
||||
"reason": v.Reason,
|
||||
"monitorMode": v.MonitorMode,
|
||||
"clientAt": v.ClientAt,
|
||||
"classRkId": v.ClassRkID,
|
||||
"examRoomId": v.ExamRoomID,
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(payload)
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
client := http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
log.Printf("[VIOLATION] report failed: %v", err)
|
||||
@@ -480,7 +539,7 @@ func (a *App) postViolation(v pendingViolation) bool {
|
||||
log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body))
|
||||
return false
|
||||
}
|
||||
log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID)
|
||||
log.Printf("[VIOLATION] reported kind=%s student=%d class=%d exam=%d", v.Kind, v.StudentRkID, v.ClassRkID, v.ExamRoomID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -488,11 +547,16 @@ func (a *App) reportViolation(kind, reason string) {
|
||||
a.mu.Lock()
|
||||
studentID := int64(0)
|
||||
mode := a.dashboard.MonitorMode
|
||||
classID := a.dashboard.ClassRkID
|
||||
examRoomID := uint(0)
|
||||
if a.dashboard.Exam != nil {
|
||||
examRoomID = a.dashboard.Exam.ExamRoomID
|
||||
}
|
||||
if a.student != nil {
|
||||
studentID = a.student.StudentID
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if studentID <= 0 {
|
||||
if studentID <= 0 || !shouldRecordViolation(mode) {
|
||||
return
|
||||
}
|
||||
v := pendingViolation{
|
||||
@@ -501,6 +565,8 @@ func (a *App) reportViolation(kind, reason string) {
|
||||
MonitorMode: mode,
|
||||
ClientAt: time.Now().Format(time.RFC3339),
|
||||
StudentRkID: studentID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
}
|
||||
if !a.postViolation(v) {
|
||||
a.enqueuePendingViolation(v)
|
||||
@@ -578,6 +644,7 @@ func (a *App) startLocalServer() {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
return
|
||||
}
|
||||
a.setLocalBrowserActive(false)
|
||||
go func() {
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowReloadApp(a.ctx)
|
||||
@@ -590,10 +657,50 @@ func (a *App) startLocalServer() {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
return
|
||||
}
|
||||
// Chỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe.
|
||||
// Chỉ xóa HTTP disk cache — không đụng cookie / localStorage (giữ phiên đăng nhập trang thi).
|
||||
go a.purgeWebViewDiskCache()
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/local-browser", func(w http.ResponseWriter, r *http.Request) {
|
||||
blocked := strings.TrimSpace(r.URL.Query().Get("blocked"))
|
||||
from := strings.TrimSpace(r.URL.Query().Get("from"))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
notice := ""
|
||||
if blocked == "1" {
|
||||
notice = `<div class="notice">Đã chặn liên kết ngoài mạng. Trình duyệt này chỉ mở <strong>localhost</strong> / <strong>127.0.0.1</strong>.</div>`
|
||||
if from != "" {
|
||||
notice += `<div class="notice-sub">Đã chặn: ` + html.EscapeString(from) + `</div>`
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, localBrowserHTML, notice)
|
||||
})
|
||||
mux.HandleFunc("/local-browser-go", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||
if raw == "" {
|
||||
http.Error(w, "missing url", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
if !isLoopbackURL(raw) {
|
||||
http.Error(w, "only localhost allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.localBrowserActive = true
|
||||
a.mu.Unlock()
|
||||
go func() {
|
||||
guard.SuppressFor(3 * time.Second)
|
||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", raw))
|
||||
}()
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
go func() {
|
||||
_ = server.ListenAndServe()
|
||||
@@ -609,18 +716,18 @@ const examViewHTML = `<!DOCTYPE html>
|
||||
<title>%s</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#1e293b;color:#f8fafc;height:100vh;display:flex;flex-direction:column}
|
||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#0f172a;border-bottom:1px solid #334155;flex-shrink:0}
|
||||
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0}
|
||||
.bar button{border:none;border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
|
||||
.btn-back{background:#7c3aed;color:#fff}
|
||||
.btn-back:hover{background:#6d28d9}
|
||||
.btn-refresh{background:#0ea5e9;color:#fff}
|
||||
.btn-refresh:hover{background:#0284c7}
|
||||
.btn-cache{background:#f59e0b;color:#111}
|
||||
.btn-cache:hover{background:#d97706}
|
||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;height:100vh;display:flex;flex-direction:column}
|
||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-shrink:0}
|
||||
.bar-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:auto;min-width:0;color:#1a2332}
|
||||
.bar button{border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
|
||||
.btn-back{background:#fff;color:#bb2126;border:1px solid #bb2126}
|
||||
.btn-back:hover{background:#fde8e9}
|
||||
.btn-refresh{background:#bb2126;color:#fff;border:1px solid #bb2126}
|
||||
.btn-refresh:hover{background:#9e1c22;border-color:#9e1c22}
|
||||
.btn-cache{background:#fff;color:#bb2126;border:1px solid #bb2126}
|
||||
.btn-cache:hover{background:#fde8e9}
|
||||
.btn-cache:disabled,.btn-refresh:disabled{opacity:.65;cursor:wait}
|
||||
.hint{font-size:11px;color:#94a3b8;flex-shrink:0}
|
||||
.hint{font-size:11px;color:#8b99a8;flex-shrink:0}
|
||||
.frame-wrap{flex:1;min-height:0;background:#fff}
|
||||
iframe{width:100%%;height:100%%;border:0;display:block}
|
||||
</style>
|
||||
@@ -629,7 +736,7 @@ iframe{width:100%%;height:100%%;border:0;display:block}
|
||||
<div class="bar">
|
||||
<button type="button" class="btn-back" onclick="goBack()">← Quay lại</button>
|
||||
<span class="bar-title">%s</span>
|
||||
<span class="hint">Nếu trang lỗi / trắng: F5 hoặc Xóa cache</span>
|
||||
<span class="hint">Trang trắng/lỗi: F5 hoặc Xóa cache (giữ đăng nhập)</span>
|
||||
<button type="button" class="btn-refresh" id="btn-refresh" onclick="refreshExam()">⟳ F5 Làm mới</button>
|
||||
<button type="button" class="btn-cache" id="btn-cache" onclick="clearExamCache()">🗑 Xóa cache</button>
|
||||
</div>
|
||||
@@ -640,58 +747,35 @@ iframe{width:100%%;height:100%%;border:0;display:block}
|
||||
function goBack(){
|
||||
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
|
||||
}
|
||||
function refreshExam(){
|
||||
function reloadFrameSoft(){
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (!f) return;
|
||||
var btn = document.getElementById('btn-refresh');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
f.contentWindow.location.reload();
|
||||
} catch (e) {
|
||||
var src = f.src;
|
||||
var src = f.getAttribute('src') || f.src;
|
||||
f.src = 'about:blank';
|
||||
setTimeout(function(){ f.src = src; }, 50);
|
||||
}
|
||||
}
|
||||
function refreshExam(){
|
||||
var btn = document.getElementById('btn-refresh');
|
||||
if (btn) btn.disabled = true;
|
||||
reloadFrameSoft();
|
||||
setTimeout(function(){ if (btn) btn.disabled = false; }, 800);
|
||||
}
|
||||
async function clearExamCache(){
|
||||
var btn = document.getElementById('btn-cache');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
if (window.caches) {
|
||||
var keys = await caches.keys();
|
||||
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try { localStorage.clear(); } catch (e) {}
|
||||
try { sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (f && f.contentWindow) {
|
||||
try { f.contentWindow.localStorage.clear(); } catch (e) {}
|
||||
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
if (f.contentWindow.caches) {
|
||||
var ikeys = await f.contentWindow.caches.keys();
|
||||
await Promise.all(ikeys.map(function(k){ return f.contentWindow.caches.delete(k); }));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
// Không xóa localStorage/sessionStorage/cookie — tránh văng đăng nhập trang thi.
|
||||
try {
|
||||
await fetch('http://127.0.0.1:34115/exam-clear-cache');
|
||||
} catch (e) {}
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (f) {
|
||||
try {
|
||||
var u = new URL(f.src, window.location.href);
|
||||
u.searchParams.set('_sc_cb', String(Date.now()));
|
||||
f.src = u.toString();
|
||||
} catch (e) {
|
||||
refreshExam();
|
||||
}
|
||||
}
|
||||
setTimeout(function(){ if (btn) btn.disabled = false; }, 1200);
|
||||
// Chờ một nhịp để disk cache kịp xóa rồi reload cùng URL (không thêm query bust).
|
||||
setTimeout(function(){
|
||||
reloadFrameSoft();
|
||||
if (btn) btn.disabled = false;
|
||||
}, 400);
|
||||
}
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'F5') {
|
||||
@@ -703,6 +787,94 @@ document.addEventListener('keydown', function(e){
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const localBrowserHTML = `<!DOCTYPE html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Trình duyệt Local — Simple Care</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;min-height:100vh;display:flex;flex-direction:column}
|
||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-wrap:wrap}
|
||||
.bar button{border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap}
|
||||
.btn-back{background:#fff;color:#bb2126;border:1px solid #bb2126}
|
||||
.btn-back:hover{background:#fde8e9}
|
||||
.btn-go{background:#bb2126;color:#fff;border:1px solid #bb2126}
|
||||
.btn-go:hover{background:#9e1c22}
|
||||
.addr{flex:1;min-width:220px;padding:8px 12px;border:1px solid #e4e9f0;border-radius:8px;font-size:13px}
|
||||
.addr:focus{outline:2px solid rgba(187,33,38,.25);border-color:#bb2126}
|
||||
.main{flex:1;padding:28px 20px;max-width:720px;margin:0 auto;width:100%%}
|
||||
h1{font-size:1.35rem;margin-bottom:8px;color:#bb2126}
|
||||
.lead{color:#5a6a7e;font-size:0.95rem;margin-bottom:18px;line-height:1.5}
|
||||
.notice{background:#fde8e9;color:#9e1c22;border:1px solid #f5c2c4;border-radius:10px;padding:12px 14px;margin-bottom:10px;font-size:0.9rem}
|
||||
.notice-sub{font-size:0.8rem;color:#8b99a8;margin-bottom:14px;word-break:break-all}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
|
||||
.chip{background:#fff;border:1px solid #e4e9f0;border-radius:999px;padding:6px 12px;font-size:12px;cursor:pointer;color:#bb2126;font-weight:600}
|
||||
.chip:hover{background:#fde8e9;border-color:#bb2126}
|
||||
.hint{margin-top:22px;font-size:12px;color:#8b99a8;line-height:1.5}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<button type="button" class="btn-back" onclick="goHome()">← Về trang chính</button>
|
||||
<input class="addr" id="addr" type="url" placeholder="http://localhost:3000" spellcheck="false" />
|
||||
<button type="button" class="btn-go" onclick="goLocal()">Mở</button>
|
||||
</div>
|
||||
<div class="main">
|
||||
%s
|
||||
<h1>Trình duyệt Local</h1>
|
||||
<p class="lead">Chỉ dùng để xem bài làm chạy trên máy bạn (<strong>localhost</strong> / <strong>127.0.0.1</strong>). Mọi link online (GitHub, Google, …) đều bị chặn tại đây.</p>
|
||||
<div class="chips">
|
||||
<button type="button" class="chip" onclick="fill('http://localhost:3000')">localhost:3000</button>
|
||||
<button type="button" class="chip" onclick="fill('http://localhost:5173')">localhost:5173</button>
|
||||
<button type="button" class="chip" onclick="fill('http://127.0.0.1:8080')">127.0.0.1:8080</button>
|
||||
<button type="button" class="chip" onclick="fill('http://localhost:5500')">localhost:5500</button>
|
||||
</div>
|
||||
<p class="hint">Mẹo: chạy dự án (npm run dev / Live Server), dán URL vào ô trên rồi bấm Mở. Về trang chính: nút ← hoặc menu Simple Care → Ctrl+H.</p>
|
||||
</div>
|
||||
<script>
|
||||
function fill(u){ document.getElementById('addr').value = u; }
|
||||
function goHome(){
|
||||
fetch('http://127.0.0.1:34115/exam-close').catch(function(){});
|
||||
}
|
||||
function isLoopback(raw){
|
||||
try {
|
||||
if (!raw) return false;
|
||||
if (raw.indexOf('://') < 0) raw = 'http://' + raw;
|
||||
var u = new URL(raw);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||||
var h = (u.hostname || '').toLowerCase();
|
||||
if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]') return true;
|
||||
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true;
|
||||
return false;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
async function goLocal(){
|
||||
var raw = (document.getElementById('addr').value || '').trim();
|
||||
if (!raw) { alert('Nhập URL localhost'); return; }
|
||||
if (!isLoopback(raw)) {
|
||||
alert('Chỉ được mở localhost / 127.0.0.1');
|
||||
return;
|
||||
}
|
||||
if (raw.indexOf('://') < 0) raw = 'http://' + raw;
|
||||
try {
|
||||
var res = await fetch('http://127.0.0.1:34115/local-browser-go?url=' + encodeURIComponent(raw));
|
||||
if (!res.ok) {
|
||||
alert('Không mở được — chỉ localhost');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
location.href = raw;
|
||||
}
|
||||
}
|
||||
document.getElementById('addr').addEventListener('keydown', function(e){
|
||||
if (e.key === 'Enter') { e.preventDefault(); goLocal(); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// loadSession nạp session từ file cục bộ
|
||||
func (a *App) loadSession() {
|
||||
a.mu.Lock()
|
||||
@@ -821,6 +993,7 @@ func (a *App) GetStudentInfo() map[string]any {
|
||||
|
||||
// NavigateToLogin chuyển WebView đến trang đăng nhập Rikkei Portal
|
||||
func (a *App) NavigateToLogin() {
|
||||
a.setLocalBrowserActive(false)
|
||||
a.mu.Lock()
|
||||
a.expectingLogin = true
|
||||
a.mu.Unlock()
|
||||
@@ -1857,10 +2030,88 @@ func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) {
|
||||
|
||||
// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
|
||||
func (a *App) ReturnToDashboard() {
|
||||
a.setLocalBrowserActive(false)
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowReloadApp(a.ctx)
|
||||
}
|
||||
|
||||
func (a *App) setLocalBrowserActive(on bool) {
|
||||
a.mu.Lock()
|
||||
a.localBrowserActive = on
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func isLoopbackURL(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func (a *App) localBrowserGuardLoop() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
a.mu.Lock()
|
||||
active := a.localBrowserActive && a.ctx != nil && !a.monitoringTornDown
|
||||
ctx := a.ctx
|
||||
a.mu.Unlock()
|
||||
if !active {
|
||||
continue
|
||||
}
|
||||
// Nếu hostname không phải loopback → đá về trang local-browser
|
||||
runtime.WindowExecJS(ctx, `
|
||||
(function(){
|
||||
try {
|
||||
var h = (location.hostname || '').toLowerCase();
|
||||
var ok = h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(h);
|
||||
if (!ok) {
|
||||
location.href = 'http://127.0.0.1:34115/local-browser?blocked=1&from=' + encodeURIComponent(location.href);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenGitHub mở github.com trong WebView (về trang chính: Ctrl+H).
|
||||
func (a *App) OpenGitHub() {
|
||||
a.setLocalBrowserActive(false)
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowExecJS(a.ctx, `window.location.href = 'https://github.com'`)
|
||||
}
|
||||
|
||||
// OpenGoogleTranslate mở Google Dịch trong WebView.
|
||||
func (a *App) OpenGoogleTranslate() {
|
||||
a.setLocalBrowserActive(false)
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowExecJS(a.ctx, `window.location.href = 'https://translate.google.com/?sl=auto&tl=vi'`)
|
||||
}
|
||||
|
||||
// OpenLocalBrowser mở trình duyệt chỉ cho phép localhost (test bài làm local).
|
||||
func (a *App) OpenLocalBrowser() {
|
||||
a.setLocalBrowserActive(true)
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowExecJS(a.ctx, `window.location.href = 'http://127.0.0.1:34115/local-browser'`)
|
||||
}
|
||||
|
||||
// ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm).
|
||||
func (a *App) ReloadExamPage() {
|
||||
guard.SuppressFor(3 * time.Second)
|
||||
@@ -1880,37 +2131,27 @@ func (a *App) ReloadExamPage() {
|
||||
`)
|
||||
}
|
||||
|
||||
// ClearExamBrowserCache — xóa cache WebView (storage + disk cache) rồi làm mới trang thi.
|
||||
// ClearExamBrowserCache — chỉ xóa HTTP disk cache, giữ cookie/localStorage (không văng đăng nhập trang thi).
|
||||
func (a *App) ClearExamBrowserCache() {
|
||||
guard.SuppressFor(8 * time.Second)
|
||||
a.purgeWebViewDiskCache()
|
||||
runtime.WindowExecJS(a.ctx, `
|
||||
(async function(){
|
||||
(function(){
|
||||
try {
|
||||
if (window.caches) {
|
||||
var keys = await caches.keys();
|
||||
await Promise.all(keys.map(function(k){ return caches.delete(k); }));
|
||||
if (typeof window.reloadFrameSoft === 'function') {
|
||||
window.reloadFrameSoft();
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
try { localStorage.clear(); } catch (e) {}
|
||||
try { sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
var f = document.getElementById('exam-frame');
|
||||
if (f) {
|
||||
try { f.contentWindow.localStorage.clear(); } catch (e) {}
|
||||
try { f.contentWindow.sessionStorage.clear(); } catch (e) {}
|
||||
try {
|
||||
var u = new URL(f.src, window.location.href);
|
||||
u.searchParams.set('_sc_cb', String(Date.now()));
|
||||
f.src = u.toString();
|
||||
return;
|
||||
} catch (e) {
|
||||
try { f.contentWindow.location.reload(); } catch (e2) {
|
||||
var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50);
|
||||
try { f.contentWindow.location.reload(); }
|
||||
catch (e) {
|
||||
var s = f.getAttribute('src') || f.src;
|
||||
f.src = 'about:blank';
|
||||
setTimeout(function(){ f.src = s; }, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
location.reload();
|
||||
})();
|
||||
@@ -1921,12 +2162,11 @@ func (a *App) purgeWebViewDiskCache() {
|
||||
if a.webviewDataPath == "" {
|
||||
return
|
||||
}
|
||||
// Chỉ Cache tải tài nguyên — KHÔNG xóa Cookies / Local Storage / Service Worker (giữ phiên đăng nhập).
|
||||
subs := []string{
|
||||
filepath.Join("EBWebView", "Default", "Cache"),
|
||||
filepath.Join("EBWebView", "Default", "Code Cache"),
|
||||
filepath.Join("EBWebView", "Default", "GPUCache"),
|
||||
filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"),
|
||||
filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"),
|
||||
}
|
||||
for _, sub := range subs {
|
||||
path := filepath.Join(a.webviewDataPath, sub)
|
||||
@@ -1948,17 +2188,13 @@ func isLocalExamURL(raw string) bool {
|
||||
}
|
||||
|
||||
func (a *App) openExamWebView(targetURL, title string) error {
|
||||
return a.openExamWebViewOpts(targetURL, title, false)
|
||||
}
|
||||
|
||||
// openExamWebViewOpts mở nội dung trong WebView.
|
||||
// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm.
|
||||
func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error {
|
||||
targetURL = strings.TrimSpace(targetURL)
|
||||
if targetURL == "" {
|
||||
return errors.New("không có nội dung để mở")
|
||||
}
|
||||
if forceWrap || isLocalExamURL(targetURL) {
|
||||
a.setLocalBrowserActive(false)
|
||||
// URL local (PDF/resource): khung exam-view. URL ngoài (quiz): mở top-level giữ first-party cookies/session.
|
||||
if isLocalExamURL(targetURL) {
|
||||
wrapper := fmt.Sprintf(
|
||||
"http://127.0.0.1:34115/exam-view?url=%s&title=%s",
|
||||
url.QueryEscape(targetURL),
|
||||
@@ -1968,7 +2204,6 @@ func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error
|
||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper))
|
||||
return nil
|
||||
}
|
||||
// Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies.
|
||||
guard.SuppressFor(5 * time.Second)
|
||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL))
|
||||
return nil
|
||||
@@ -2060,8 +2295,9 @@ func (a *App) OpenExamQuiz() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi.
|
||||
return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true)
|
||||
// Mở trực tiếp (không iframe) để cookie/localStorage đăng nhập web quiz là first-party.
|
||||
// F5 / Xóa cache / Về trang chính: menu Simple Care.
|
||||
return a.openExamWebView(viewURL, "Làm trắc nghiệm")
|
||||
}
|
||||
|
||||
func (a *App) OpenExamResource(fileID uint) error {
|
||||
@@ -2198,7 +2434,7 @@ func (a *App) DownloadExamResource(fileID uint) (string, error) {
|
||||
// LoadExamViewFile tải file đề/tài nguyên qua Go (không mở URL trực tiếp trong WebView).
|
||||
func (a *App) LoadExamViewFile(fileURL string) (map[string]any, error) {
|
||||
fileURL = strings.TrimSpace(fileURL)
|
||||
if !strings.HasPrefix(fileURL, API_BASE + "/api/student/exam/download") {
|
||||
if !strings.HasPrefix(fileURL, API_BASE+"/api/student/exam/download") {
|
||||
return nil, errors.New("URL không hợp lệ")
|
||||
}
|
||||
if _, _, err := a.examStudentContext(); err != nil {
|
||||
@@ -2274,7 +2510,7 @@ func (a *App) SubmitExamWork() (string, error) {
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", API_BASE + "/api/student/exam/submit", &body)
|
||||
req, err := http.NewRequest("POST", API_BASE+"/api/student/exam/submit", &body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
4
client/frontend/package-lock.json
generated
4
client/frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"pdfjs-dist": "^3.11.174"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -1 +1 @@
|
||||
967eadb7b0ec47c936144695be4b0b15
|
||||
792441f7861d7badf1335f3778fcdebd
|
||||
@@ -18,10 +18,12 @@
|
||||
--offline-light: #fdeaea;
|
||||
--shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05);
|
||||
--shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-sm: 7px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
--transition: 0.2s ease;
|
||||
--page-pad: 0.75rem 0.9rem;
|
||||
--card-pad: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -32,7 +34,8 @@
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -40,18 +43,22 @@ body {
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
@@ -60,7 +67,7 @@ body {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.75rem;
|
||||
padding: var(--card-pad);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
@@ -70,10 +77,10 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1.5rem;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--bg-main);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.login-prompt-container .card {
|
||||
@@ -94,8 +101,8 @@ body {
|
||||
}
|
||||
|
||||
.brand-logo-img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
@@ -103,8 +110,8 @@ body {
|
||||
}
|
||||
|
||||
.login-brand-row .brand-logo-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
@@ -133,6 +140,10 @@ body {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.login-brand-name .app-version {
|
||||
font-size: 0.72em;
|
||||
}
|
||||
|
||||
.login-brand-sub {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
@@ -159,11 +170,13 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
padding: 1.25rem 1.5rem;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
padding: var(--page-pad);
|
||||
padding-bottom: 1.25rem;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
animation: fadeIn 0.35s ease-out;
|
||||
}
|
||||
|
||||
@@ -171,10 +184,14 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.85rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -184,15 +201,15 @@ body {
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.75rem;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -205,25 +222,36 @@ body {
|
||||
|
||||
.brand-name {
|
||||
font-weight: 800;
|
||||
font-size: 1.05rem;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 0.68em;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0;
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) 1fr;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(180px, 220px) 1fr;
|
||||
gap: 0.75rem;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ── Profile Card ── */
|
||||
@@ -232,25 +260,25 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow: visible;
|
||||
justify-content: flex-start;
|
||||
padding-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--accent-light);
|
||||
padding: 3px;
|
||||
border: 2px solid var(--accent-light);
|
||||
padding: 2px;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
@@ -276,7 +304,7 @@ body {
|
||||
|
||||
.student-name {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.3px;
|
||||
@@ -285,12 +313,12 @@ body {
|
||||
.student-code {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
padding: 3px 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.3rem;
|
||||
margin-bottom: 0.35rem;
|
||||
border: 1px solid rgba(187, 33, 38, 0.15);
|
||||
letter-spacing: 0.3px;
|
||||
display: inline-block;
|
||||
@@ -299,22 +327,22 @@ body {
|
||||
.student-email {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 1.25rem 0;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.profile-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.profile-info-row span {
|
||||
@@ -330,17 +358,17 @@ body {
|
||||
.right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
gap: 0.65rem;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
gap: 0.55rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -393,13 +421,13 @@ body {
|
||||
.exam-panel {
|
||||
border: 1px solid #c4b5fd;
|
||||
background: linear-gradient(135deg, #faf5ff 0%, #fff 100%);
|
||||
padding: 1rem 1.15rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.exam-panel-desc {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -410,6 +438,38 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tools-panel {
|
||||
margin-bottom: 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid #f5c2c4;
|
||||
background: linear-gradient(135deg, #fff8f8 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.tools-panel-desc {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tools-panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.btn-tool:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.exam-wait, .exam-done {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
@@ -608,7 +668,7 @@ body {
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stats-grid--two {
|
||||
@@ -957,9 +1017,10 @@ body {
|
||||
}
|
||||
|
||||
.shifts-wrap {
|
||||
flex: 1;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.shifts-table {
|
||||
@@ -1028,14 +1089,14 @@ body {
|
||||
}
|
||||
|
||||
.status-banner-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--accent-light);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1048,7 +1109,7 @@ body {
|
||||
}
|
||||
|
||||
.status-banner-value {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -1056,19 +1117,19 @@ body {
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.9rem 1rem;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1088,7 +1149,7 @@ body {
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
@@ -1100,11 +1161,11 @@ body {
|
||||
.clocks-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
gap: 0.25rem;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem 0.65rem;
|
||||
overflow: visible;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@@ -1161,11 +1222,11 @@ body {
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
padding: 0.65rem 1.15rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.78rem;
|
||||
font-family: 'Be Vietnam Pro', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
@@ -1187,8 +1248,8 @@ body {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
@@ -1197,6 +1258,69 @@ body {
|
||||
border-color: rgba(214, 48, 49, 0.25);
|
||||
}
|
||||
|
||||
/* ── Compact / small screens ── */
|
||||
@media (max-width: 860px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
gap: 0.65rem 0.85rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.profile-card .student-name,
|
||||
.profile-card .student-code,
|
||||
.profile-card .student-email {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-card .divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-info-row {
|
||||
width: auto;
|
||||
flex: 1 1 100%;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
:root {
|
||||
--page-pad: 0.55rem 0.6rem;
|
||||
--card-pad: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.tools-panel-actions,
|
||||
.exam-panel-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tools-panel-actions .btn,
|
||||
.exam-panel-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clock-inline {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-title-row--shifts {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes floatIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
|
||||
@@ -19,6 +19,7 @@ if (typeof window.go.main.App === 'undefined') {
|
||||
}
|
||||
|
||||
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
|
||||
const APP_VERSION = '1.3';
|
||||
|
||||
// Trạng thái cục bộ
|
||||
let loggedIn = false;
|
||||
@@ -126,7 +127,7 @@ function renderLoginPrompt() {
|
||||
<div class="login-brand-row">
|
||||
${brandLogoHtml}
|
||||
<div class="login-brand-text">
|
||||
<div class="login-brand-name">Simple Care</div>
|
||||
<div class="login-brand-name">Simple Care <span class="app-version">v${APP_VERSION}</span></div>
|
||||
<div class="login-brand-sub">Rikkei Education</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,7 +414,7 @@ function renderExamPanel() {
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
||||
</div>
|
||||
<p class="exam-panel-desc">Đề PDF và tài nguyên xem trong app. Trắc nghiệm có nút <strong>F5</strong> / <strong>Xóa cache</strong> trên thanh công cụ. Xong: menu <strong>Simple Care → Về trang chính</strong> (Ctrl+H).</p>
|
||||
<p class="exam-panel-desc">Đề PDF và tài nguyên xem trong app. Trắc nghiệm mở trang quiz trực tiếp (giữ đăng nhập). F5 / Xóa cache / Về trang chính: menu <strong>Simple Care</strong> (F5, Ctrl+Delete, Ctrl+H).</p>
|
||||
<div class="exam-panel-actions">
|
||||
${paperBtn}
|
||||
${quizBtn}
|
||||
@@ -498,7 +499,7 @@ function renderDashboard() {
|
||||
<div class="brand">
|
||||
${brandLogoHtml}
|
||||
<div class="brand-text">
|
||||
<span class="brand-name">Simple Care</span>
|
||||
<span class="brand-name">Simple Care <span class="app-version">v${APP_VERSION}</span></span>
|
||||
<span class="brand-sub">Rikkei Education</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,6 +545,18 @@ function renderDashboard() {
|
||||
|
||||
<div id="exam-panel-host">${renderExamPanel()}</div>
|
||||
|
||||
<div class="card tools-panel">
|
||||
<div class="card-title-row">
|
||||
<div class="card-title">Công cụ học tập</div>
|
||||
</div>
|
||||
<p class="tools-panel-desc">GitHub & Google Dịch mở trong app. Trình duyệt Local chỉ chạy link <strong>localhost</strong> để test bài làm.</p>
|
||||
<div class="tools-panel-actions">
|
||||
<button type="button" class="btn btn-tool" id="btn-open-github">GitHub</button>
|
||||
<button type="button" class="btn btn-tool" id="btn-open-translate">Google Dịch</button>
|
||||
<button type="button" class="btn btn-primary" id="btn-open-local-browser">Trình duyệt Local</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid stats-grid--two">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-icon-wrap">📡</div>
|
||||
@@ -592,6 +605,15 @@ function renderDashboard() {
|
||||
await window.go.main.App.Logout();
|
||||
}
|
||||
});
|
||||
document.getElementById('btn-open-github')?.addEventListener('click', () => {
|
||||
window.go.main.App.OpenGitHub();
|
||||
});
|
||||
document.getElementById('btn-open-translate')?.addEventListener('click', () => {
|
||||
window.go.main.App.OpenGoogleTranslate();
|
||||
});
|
||||
document.getElementById('btn-open-local-browser')?.addEventListener('click', () => {
|
||||
window.go.main.App.OpenLocalBrowser();
|
||||
});
|
||||
wireExamPanel();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
12
client/frontend/wailsjs/go/main/App.d.ts
vendored
12
client/frontend/wailsjs/go/main/App.d.ts
vendored
@@ -5,6 +5,8 @@ export function CheckLoginStatus():Promise<boolean>;
|
||||
|
||||
export function ClearChatUnread():Promise<void>;
|
||||
|
||||
export function ClearExamBrowserCache():Promise<void>;
|
||||
|
||||
export function DownloadExamResource(arg1:number):Promise<string>;
|
||||
|
||||
export function GetChatConversations():Promise<Array<Record<string, any>>>;
|
||||
@@ -23,6 +25,8 @@ export function GetStats():Promise<Record<string, any>>;
|
||||
|
||||
export function GetStudentInfo():Promise<Record<string, any>>;
|
||||
|
||||
export function HandleBeforeClose():Promise<boolean>;
|
||||
|
||||
export function LoadExamViewFile(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function Logout():Promise<void>;
|
||||
@@ -35,6 +39,14 @@ export function OpenExamQuiz():Promise<void>;
|
||||
|
||||
export function OpenExamResource(arg1:number):Promise<void>;
|
||||
|
||||
export function OpenGitHub():Promise<void>;
|
||||
|
||||
export function OpenGoogleTranslate():Promise<void>;
|
||||
|
||||
export function OpenLocalBrowser():Promise<void>;
|
||||
|
||||
export function ReloadExamPage():Promise<void>;
|
||||
|
||||
export function ReturnToDashboard():Promise<void>;
|
||||
|
||||
export function SendChatMessage(arg1:string):Promise<void>;
|
||||
|
||||
@@ -10,82 +10,106 @@ export function ClearChatUnread() {
|
||||
return ObfuscatedCall(1, []);
|
||||
}
|
||||
|
||||
export function ClearExamBrowserCache() {
|
||||
return ObfuscatedCall(2, []);
|
||||
}
|
||||
|
||||
export function DownloadExamResource(arg1) {
|
||||
return ObfuscatedCall(2, [arg1]);
|
||||
return ObfuscatedCall(3, [arg1]);
|
||||
}
|
||||
|
||||
export function GetChatConversations() {
|
||||
return ObfuscatedCall(3, []);
|
||||
return ObfuscatedCall(4, []);
|
||||
}
|
||||
|
||||
export function GetChatMessages(arg1) {
|
||||
return ObfuscatedCall(4, [arg1]);
|
||||
return ObfuscatedCall(5, [arg1]);
|
||||
}
|
||||
|
||||
export function GetChatUnread() {
|
||||
return ObfuscatedCall(5, []);
|
||||
}
|
||||
|
||||
export function GetExamPaperFiles() {
|
||||
return ObfuscatedCall(6, []);
|
||||
}
|
||||
|
||||
export function GetExamPaperViewURL() {
|
||||
export function GetExamPaperFiles() {
|
||||
return ObfuscatedCall(7, []);
|
||||
}
|
||||
|
||||
export function GetExamQuizViewURL() {
|
||||
export function GetExamPaperViewURL() {
|
||||
return ObfuscatedCall(8, []);
|
||||
}
|
||||
|
||||
export function GetStats() {
|
||||
export function GetExamQuizViewURL() {
|
||||
return ObfuscatedCall(9, []);
|
||||
}
|
||||
|
||||
export function GetStudentInfo() {
|
||||
export function GetStats() {
|
||||
return ObfuscatedCall(10, []);
|
||||
}
|
||||
|
||||
export function LoadExamViewFile(arg1) {
|
||||
return ObfuscatedCall(11, [arg1]);
|
||||
export function GetStudentInfo() {
|
||||
return ObfuscatedCall(11, []);
|
||||
}
|
||||
|
||||
export function Logout() {
|
||||
export function HandleBeforeClose() {
|
||||
return ObfuscatedCall(12, []);
|
||||
}
|
||||
|
||||
export function NavigateToLogin() {
|
||||
return ObfuscatedCall(13, []);
|
||||
export function LoadExamViewFile(arg1) {
|
||||
return ObfuscatedCall(13, [arg1]);
|
||||
}
|
||||
|
||||
export function OpenExamPaper() {
|
||||
export function Logout() {
|
||||
return ObfuscatedCall(14, []);
|
||||
}
|
||||
|
||||
export function OpenExamQuiz() {
|
||||
export function NavigateToLogin() {
|
||||
return ObfuscatedCall(15, []);
|
||||
}
|
||||
|
||||
export function OpenExamResource(arg1) {
|
||||
return ObfuscatedCall(16, [arg1]);
|
||||
export function OpenExamPaper() {
|
||||
return ObfuscatedCall(16, []);
|
||||
}
|
||||
|
||||
export function ReturnToDashboard() {
|
||||
export function OpenExamQuiz() {
|
||||
return ObfuscatedCall(17, []);
|
||||
}
|
||||
|
||||
export function SendChatMessage(arg1) {
|
||||
export function OpenExamResource(arg1) {
|
||||
return ObfuscatedCall(18, [arg1]);
|
||||
}
|
||||
|
||||
export function SendWebcamFrame(arg1) {
|
||||
return ObfuscatedCall(19, [arg1]);
|
||||
export function OpenGitHub() {
|
||||
return ObfuscatedCall(19, []);
|
||||
}
|
||||
|
||||
export function SubmitExamWork() {
|
||||
export function OpenGoogleTranslate() {
|
||||
return ObfuscatedCall(20, []);
|
||||
}
|
||||
|
||||
export function UnlockChatAudio() {
|
||||
export function OpenLocalBrowser() {
|
||||
return ObfuscatedCall(21, []);
|
||||
}
|
||||
|
||||
export function ReloadExamPage() {
|
||||
return ObfuscatedCall(22, []);
|
||||
}
|
||||
|
||||
export function ReturnToDashboard() {
|
||||
return ObfuscatedCall(23, []);
|
||||
}
|
||||
|
||||
export function SendChatMessage(arg1) {
|
||||
return ObfuscatedCall(24, [arg1]);
|
||||
}
|
||||
|
||||
export function SendWebcamFrame(arg1) {
|
||||
return ObfuscatedCall(25, [arg1]);
|
||||
}
|
||||
|
||||
export function SubmitExamWork() {
|
||||
return ObfuscatedCall(26, []);
|
||||
}
|
||||
|
||||
export function UnlockChatAudio() {
|
||||
return ObfuscatedCall(27, []);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,23 @@ var systemAllowed = map[string]bool{
|
||||
"biometricd": true,
|
||||
"touchidd": true,
|
||||
|
||||
// ── Security utilities / AV (from app_pool + common) ──────────────────────
|
||||
"activity monitor": true, // System monitor (app_pool)
|
||||
"passwords": true, // Apple Passwords / iCloud Keychain (app_pool)
|
||||
"xprotectservice": true, // macOS built-in malware protection
|
||||
"xprotect": true,
|
||||
"malware removal tool": true,
|
||||
"mrt": true,
|
||||
"avast": true,
|
||||
"avast security": true,
|
||||
"bitdefender": true,
|
||||
"norton": true,
|
||||
"sophos": true,
|
||||
"malwarebytes": true,
|
||||
"eset": true,
|
||||
"little snitch": true,
|
||||
"lulu": true,
|
||||
|
||||
// ── Audio / Media ─────────────────────────────────────────────────────────
|
||||
"coreaudiod": true, // Core Audio daemon — kill = no sound
|
||||
"audioundockhelper": true,
|
||||
@@ -154,6 +171,9 @@ var systemAllowed = map[string]bool{
|
||||
// ── Our app + IDE/dev tools ────────────────────────────────────────────────
|
||||
"client": true,
|
||||
"simple_care_v1.0": true,
|
||||
"simple_care_v1.1": true,
|
||||
"simple_care_v1.2": true,
|
||||
"simple_care_v1.3": true,
|
||||
"simple_care": true,
|
||||
"wails": true,
|
||||
"code": true, // VSCode
|
||||
|
||||
@@ -205,6 +205,21 @@ var systemAllowed = map[string]bool{
|
||||
"WebKitWebProcess": true,
|
||||
"WebKitNetworkPro": true, // prefix
|
||||
|
||||
// ── Security / antivirus / updates (from app_pool + common) ───────────────
|
||||
"update-notifier": true, // Ubuntu update notifier (app_pool)
|
||||
"apport-gtk": true, // Ubuntu crash reporter (app_pool)
|
||||
"kgpg": true, // KDE GPG encryption (app_pool)
|
||||
"ksecretd": true, // KDE secrets daemon (app_pool)
|
||||
"clamtk": true,
|
||||
"clamav": true,
|
||||
"seahorse": true, // GNOME password/keys manager
|
||||
"kwalletd": true,
|
||||
"kwalletd5": true,
|
||||
"kwalletd6": true,
|
||||
"gufw": true, // UFW firewall GUI
|
||||
"firewalld": true,
|
||||
"pkexec": true, // Polkit privilege elevation prompts
|
||||
|
||||
// ── Remote support tools (safety) ────────────────────────────────────────
|
||||
"rustdesk": true,
|
||||
"rustdesk-bin": true,
|
||||
@@ -221,6 +236,9 @@ var systemAllowed = map[string]bool{
|
||||
"idea": true,
|
||||
"client": true,
|
||||
"simple_care_v1.0": true,
|
||||
"simple_care_v1.1": true,
|
||||
"simple_care_v1.2": true,
|
||||
"simple_care_v1.3": true,
|
||||
"simple_care": true,
|
||||
}
|
||||
|
||||
|
||||
@@ -285,14 +285,27 @@ var systemAllowed = map[string]bool{
|
||||
"atkex.exe": true,
|
||||
"asusoptimization.exe": true,
|
||||
|
||||
// ── Antivirus trays (common, prevent false-kill) ───────────────────────────
|
||||
// ── Antivirus / endpoint security (from app_pool + common) ────────────────
|
||||
"avastui.exe": true,
|
||||
"avgui.exe": true,
|
||||
"mbam.exe": true, // Malwarebytes
|
||||
"mbamtray.exe": true,
|
||||
"bdagent.exe": true, // Bitdefender
|
||||
"bdwtxag.exe": true, // Bitdefender widget agent
|
||||
"uiseagnt.exe": true, // Trend Micro
|
||||
"eguiproxy.exe": true, // ESET
|
||||
"eguiproxy.exe": true, // ESET proxy
|
||||
"egui.exe": true, // ESET NOD32 GUI (app_pool)
|
||||
"microsoftsecurityapp.exe": true, // Microsoft Defender app (app_pool)
|
||||
"msascuil.exe": true, // Defender notification icon
|
||||
"pickerhost.exe": true, // Windows Security picker (app_pool)
|
||||
"seccenter.exe": true, // Security center dialogs (app_pool)
|
||||
"mc-web-view.exe": true, // McAfee web view (app_pool)
|
||||
"rsappui.exe": true, // RAV Endpoint Protection (app_pool)
|
||||
"avpui.exe": true, // Kaspersky UI
|
||||
"ksdeui.exe": true, // Kaspersky Secure Connection
|
||||
"n360.exe": true, // Norton 360
|
||||
"nortonsecurity.exe": true,
|
||||
"sophos ui.exe": true, // Sophos UI
|
||||
|
||||
// ── Task Manager & System tools ────────────────────────────────────────────
|
||||
"taskmgr.exe": true,
|
||||
@@ -316,6 +329,9 @@ var systemAllowed = map[string]bool{
|
||||
// ── Our app + IDE/dev tools ────────────────────────────────────────────────
|
||||
"client.exe": true,
|
||||
"simple_care_v1.0.exe": true,
|
||||
"simple_care_v1.1.exe": true,
|
||||
"simple_care_v1.2.exe": true,
|
||||
"simple_care_v1.3.exe": true,
|
||||
"simple_care.exe": true,
|
||||
"wails.exe": true,
|
||||
"msedgewebview2.exe": true, // WebView2 runtime (Wails renderer)
|
||||
|
||||
@@ -47,8 +47,53 @@ static unsigned char* CaptureFullDesktop(int* outLen, int quality) {
|
||||
}
|
||||
|
||||
@autoreleasepool {
|
||||
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:image];
|
||||
// Calculate new size maintaining aspect ratio (limiting to max 1024x768)
|
||||
CGFloat originalWidth = CGImageGetWidth(image);
|
||||
CGFloat originalHeight = CGImageGetHeight(image);
|
||||
CGFloat maxWidth = 1024.0f;
|
||||
CGFloat maxHeight = 768.0f;
|
||||
CGFloat ratio = 1.0f;
|
||||
if (originalWidth > maxWidth || originalHeight > maxHeight) {
|
||||
CGFloat ratioW = maxWidth / originalWidth;
|
||||
CGFloat ratioH = maxHeight / originalHeight;
|
||||
ratio = ratioW < ratioH ? ratioW : ratioH;
|
||||
}
|
||||
size_t newWidth = (size_t)(originalWidth * ratio);
|
||||
size_t newHeight = (size_t)(originalHeight * ratio);
|
||||
if (newWidth < 1) newWidth = 1;
|
||||
if (newHeight < 1) newHeight = 1;
|
||||
|
||||
// Create bitmap context
|
||||
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
|
||||
CGContextRef context = CGBitmapContextCreate(NULL,
|
||||
newWidth,
|
||||
newHeight,
|
||||
8,
|
||||
newWidth * 4,
|
||||
colorSpace,
|
||||
kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
|
||||
CGColorSpaceRelease(colorSpace);
|
||||
if (!context) {
|
||||
CGImageRelease(image);
|
||||
*outLen = 0;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Draw image into context to resize
|
||||
CGContextSetInterpolationQuality(context, kCGInterpolationHigh);
|
||||
CGContextDrawImage(context, CGRectMake(0, 0, newWidth, newHeight), image);
|
||||
CGImageRelease(image);
|
||||
|
||||
// Get resized image
|
||||
CGImageRef resizedImage = CGBitmapContextCreateImage(context);
|
||||
CGContextRelease(context);
|
||||
if (!resizedImage) {
|
||||
*outLen = 0;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:resizedImage];
|
||||
CGImageRelease(resizedImage);
|
||||
|
||||
if (!rep) {
|
||||
*outLen = 0;
|
||||
|
||||
@@ -6,11 +6,35 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
|
||||
"github.com/kbinani/screenshot"
|
||||
)
|
||||
|
||||
// resizeRGBA resizes a *image.RGBA image using Nearest-Neighbor scaling for maximum performance.
|
||||
func resizeRGBA(src *image.RGBA, width, height int) *image.RGBA {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
srcBounds := src.Bounds()
|
||||
dx := srcBounds.Dx()
|
||||
dy := srcBounds.Dy()
|
||||
minX := srcBounds.Min.X
|
||||
minY := srcBounds.Min.Y
|
||||
|
||||
for y := 0; y < height; y++ {
|
||||
srcY := minY + (y*dy)/height
|
||||
for x := 0; x < width; x++ {
|
||||
srcX := minX + (x*dx)/width
|
||||
|
||||
srcOffset := src.PixOffset(srcX, srcY)
|
||||
dstOffset := dst.PixOffset(x, y)
|
||||
|
||||
copy(dst.Pix[dstOffset:dstOffset+4], src.Pix[srcOffset:srcOffset+4])
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..."
|
||||
// Non-darwin: dùng kbinani/screenshot
|
||||
func CaptureScreen() (string, error) {
|
||||
@@ -26,9 +50,36 @@ func CaptureScreen() (string, error) {
|
||||
return "", fmt.Errorf("failed to capture screen: %w", err)
|
||||
}
|
||||
|
||||
// Tối ưu hóa kích thước ảnh giống dự án Raia:
|
||||
// Giới hạn chiều rộng tối đa là 1024px và chiều cao tối đa là 768px để giảm dung lượng mạng.
|
||||
dx := bounds.Dx()
|
||||
dy := bounds.Dy()
|
||||
|
||||
maxWidth := 1024
|
||||
maxHeight := 768
|
||||
|
||||
newWidth := dx
|
||||
newHeight := dy
|
||||
|
||||
if dx > maxWidth || dy > maxHeight {
|
||||
ratioWidth := float64(maxWidth) / float64(dx)
|
||||
ratioHeight := float64(maxHeight) / float64(dy)
|
||||
ratio := ratioWidth
|
||||
if ratioHeight < ratioWidth {
|
||||
ratio = ratioHeight
|
||||
}
|
||||
newWidth = int(float64(dx) * ratio)
|
||||
newHeight = int(float64(dy) * ratio)
|
||||
}
|
||||
|
||||
var finalImg image.Image = img
|
||||
if newWidth != dx || newHeight != dy {
|
||||
finalImg = resizeRGBA(img, newWidth, newHeight)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
// Nén chất lượng JPEG khoảng 50% để truyền tải mượt mà qua mạng
|
||||
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 50})
|
||||
err = jpeg.Encode(&buf, finalImg, &jpeg.Options{Quality: 50})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode jpeg: %w", err)
|
||||
}
|
||||
|
||||
11
client/internal/singleinstance/singleinstance.go
Normal file
11
client/internal/singleinstance/singleinstance.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package singleinstance
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrAlreadyRunning — đã có một tiến trình Simple Care đang chạy.
|
||||
var ErrAlreadyRunning = errors.New("simple care already running")
|
||||
|
||||
const (
|
||||
mutexName = "Local\\SimpleCare_SingleInstance"
|
||||
windowTitle = "Simple Care"
|
||||
)
|
||||
49
client/internal/singleinstance/singleinstance_unix.go
Normal file
49
client/internal/singleinstance/singleinstance_unix.go
Normal file
@@ -0,0 +1,49 @@
|
||||
//go:build unix
|
||||
|
||||
package singleinstance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"client/internal/winapi"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Giữ file lock mở suốt đời process.
|
||||
var lockFile *os.File
|
||||
|
||||
// Acquire — flock non-blocking trên file trong config dir.
|
||||
func Acquire() error {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
configDir = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(configDir, "SimpleCare")
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
path := filepath.Join(dir, "instance.lock")
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
|
||||
_ = f.Close()
|
||||
winapi.ActivateAppWindow(windowTitle)
|
||||
winapi.ShowWarningMessageBox(
|
||||
"Simple Care",
|
||||
"Ứng dụng Simple Care đang chạy.\n\nChỉ được mở một cửa sổ.",
|
||||
)
|
||||
// Cho dialog async kịp hiện trước khi process thoát.
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
fmt.Fprintln(os.Stderr, "Simple Care is already running")
|
||||
return ErrAlreadyRunning
|
||||
}
|
||||
_, _ = f.WriteString(fmt.Sprintf("%d\n", os.Getpid()))
|
||||
_ = f.Sync()
|
||||
lockFile = f
|
||||
return nil
|
||||
}
|
||||
47
client/internal/singleinstance/singleinstance_windows.go
Normal file
47
client/internal/singleinstance/singleinstance_windows.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build windows
|
||||
|
||||
package singleinstance
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"client/internal/winapi"
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procCreateMutexW = kernel32.NewProc("CreateMutexW")
|
||||
user32 = syscall.NewLazyDLL("user32.dll")
|
||||
procMessageBoxW = user32.NewProc("MessageBoxW")
|
||||
|
||||
// Giữ handle mutex suốt đời process — đóng = nhả lock.
|
||||
mutexHandle uintptr
|
||||
)
|
||||
|
||||
const errorAlreadyExists = 183
|
||||
|
||||
// Acquire — chỉ cho phép 1 instance. Instance thứ 2: đưa cửa sổ cũ lên rồi báo lỗi.
|
||||
func Acquire() error {
|
||||
namePtr, err := syscall.UTF16PtrFromString(mutexName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r1, _, lastErr := procCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
|
||||
if r1 == 0 {
|
||||
return lastErr
|
||||
}
|
||||
mutexHandle = r1
|
||||
if errno, ok := lastErr.(syscall.Errno); ok && errno == errorAlreadyExists {
|
||||
winapi.ActivateAppWindow(windowTitle)
|
||||
showAlreadyRunningDialog()
|
||||
return ErrAlreadyRunning
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func showAlreadyRunningDialog() {
|
||||
title, _ := syscall.UTF16PtrFromString("Simple Care")
|
||||
msg, _ := syscall.UTF16PtrFromString("Ứng dụng Simple Care đang chạy.\n\nChỉ được mở một cửa sổ. Cửa sổ hiện có đã được đưa lên phía trước.")
|
||||
_, _, _ = procMessageBoxW.Call(0, uintptr(unsafe.Pointer(msg)), uintptr(unsafe.Pointer(title)), 0x00000040) // MB_ICONINFORMATION
|
||||
}
|
||||
@@ -4,14 +4,23 @@ package winapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// ActivateAppWindow — đưa cửa sổ app lên trước
|
||||
// ActivateAppWindow — đưa cửa sổ Simple Care đang chạy lên trước (theo tên process / title).
|
||||
func ActivateAppWindow(titleHint string) {
|
||||
pid := os.Getpid()
|
||||
script := fmt.Sprintf(`tell application "System Events" to set frontmost of first process whose unix id is %d to true`, pid)
|
||||
hint := titleHint
|
||||
if hint == "" {
|
||||
hint = "Simple Care"
|
||||
}
|
||||
script := fmt.Sprintf(`
|
||||
tell application "System Events"
|
||||
set candidates to every process whose name contains %q or name contains "simple_care" or name contains "SimpleCare"
|
||||
if (count of candidates) > 0 then
|
||||
set frontmost of item 1 of candidates to true
|
||||
end if
|
||||
end tell
|
||||
`, hint)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"client/internal/singleinstance"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu/keys"
|
||||
@@ -34,6 +36,10 @@ func initLogging() *os.File {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := singleinstance.Acquire(); err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
lf := initLogging()
|
||||
if lf != nil {
|
||||
defer lf.Close()
|
||||
@@ -56,7 +62,7 @@ func main() {
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Simple Care — Rikkei Education",
|
||||
Title: "Simple Care v1.3 — Rikkei Education",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
Menu: appMenu,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "Simple Care by Rikkei Edu",
|
||||
"outputfilename": "simple_care_v1.0",
|
||||
"outputfilename": "simple_care_v1.3",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
|
||||
86
management/package-lock.json
generated
86
management/package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
@@ -266,29 +267,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -540,9 +518,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -560,9 +535,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -580,9 +552,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -600,9 +569,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -620,9 +586,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -640,9 +603,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -660,9 +620,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -680,9 +637,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -853,9 +807,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -873,9 +824,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -893,9 +841,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -913,9 +858,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -933,9 +875,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -953,9 +892,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1041,6 +977,7 @@
|
||||
"integrity": "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
@@ -1135,6 +1072,7 @@
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -1145,6 +1083,7 @@
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1243,6 +1182,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -1298,6 +1238,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
@@ -1822,9 +1763,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1846,9 +1784,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1870,9 +1805,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1894,9 +1826,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2311,6 +2240,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2365,6 +2295,7 @@
|
||||
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.137.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
@@ -2648,6 +2579,7 @@
|
||||
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { apiMyClasses } from './api';
|
||||
import { DashboardTab } from './components/DashboardTab';
|
||||
import { ClassesTab } from './components/ClassesTab';
|
||||
import { StudentsTab } from './components/StudentsTab';
|
||||
@@ -9,6 +10,8 @@ import { SystemTab } from './components/SystemTab';
|
||||
import { MyAccountTab } from './components/AccountsTab';
|
||||
import { StudentAffairsTab } from './components/StudentAffairsTab';
|
||||
import { ApplicationsTab } from './components/ApplicationsTab';
|
||||
import { RescideTab } from './components/RescideTab';
|
||||
import { RescideAnnouncePopup } from './components/RescideAnnouncePopup';
|
||||
import { ChatWidget } from './components/ChatWidget';
|
||||
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||
@@ -27,67 +30,95 @@ import {
|
||||
import { useAuth } from './auth/AuthContext';
|
||||
|
||||
const IconDashboard = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStudent = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconLearning = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconExam = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSystem = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStudentAffairs = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconApplications = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<path d="M3 14h7v7H3z" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
<path d="M7 8h.01M12 8h.01M17 8h.01M7 12h10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRescide = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
<line x1="14" y1="4" x2="10" y2="20" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMyClass = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 3 2 9l10 6 10-6-10-6Z" />
|
||||
<path d="M6 12v5c0 1.7 2.7 3 6 3s6-1.3 6-3v-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconLogout = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMenu = () => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -117,25 +148,22 @@ function App() {
|
||||
setSidebarCollapsed(nextVal);
|
||||
localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
|
||||
};
|
||||
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sc_my_classes_details');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [myClasses, setMyClasses] = useState<{ id: number; name: string; code: string }[]>([]);
|
||||
|
||||
const refreshMyClasses = useCallback(() => {
|
||||
apiMyClasses.list().then((items) => {
|
||||
setMyClasses(items.map((c) => ({ id: c.rkId, name: c.name, code: c.classCode })));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('sc_my_classes_details');
|
||||
setMyClasses(stored ? JSON.parse(stored) : []);
|
||||
} catch (e) { }
|
||||
};
|
||||
window.addEventListener('my-classes-updated', handleUpdate);
|
||||
return () => window.removeEventListener('my-classes-updated', handleUpdate);
|
||||
}, []);
|
||||
refreshMyClasses();
|
||||
}, [refreshMyClasses]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('my-classes-updated', refreshMyClasses);
|
||||
return () => window.removeEventListener('my-classes-updated', refreshMyClasses);
|
||||
}, [refreshMyClasses]);
|
||||
|
||||
useEffect(() => {
|
||||
const initial = parseRoute();
|
||||
@@ -212,122 +240,144 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<nav className="sidebar-nav" aria-label="Menu chính">
|
||||
<ul className="nav-links">
|
||||
<div className="nav-header">Tổng quan</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Tổng quan</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
>
|
||||
<span className="nav-icon"><IconDashboard /></span>
|
||||
Tổng quan & Tài Liệu
|
||||
<span className="nav-label">Tổng quan & Tài liệu</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">CSDL liên kết</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">CSDL liên kết</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
||||
<button type="button" className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
||||
<span className="nav-icon"><IconClass /></span>
|
||||
Lớp học
|
||||
<span className="nav-label">Lớp học</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'students' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('students')}
|
||||
>
|
||||
<span className="nav-icon"><IconStudent /></span>
|
||||
Sinh viên
|
||||
<span className="nav-label">Sinh viên</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">Quản lý học tập</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Quản lý học tập</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
||||
<button type="button" className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
||||
<span className="nav-icon"><IconLearning /></span>
|
||||
Phòng Học
|
||||
<span className="nav-label">Phòng học</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
||||
<button type="button" className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
||||
<span className="nav-icon"><IconExam /></span>
|
||||
Phòng thi
|
||||
<span className="nav-label">Phòng thi</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{myClasses.length > 0 && (
|
||||
<>
|
||||
<div className="nav-header">Lớp của tôi</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Lớp của tôi</span>
|
||||
</li>
|
||||
{myClasses.map((c) => (
|
||||
<li key={c.id} className="nav-item">
|
||||
<button
|
||||
className={navBtn(route.classId === c.id && route.tab === 'learning')}
|
||||
type="button"
|
||||
className={`${navBtn(route.classId === c.id && route.tab === 'learning')} nav-btn--class`}
|
||||
onClick={() => {
|
||||
navigate('learning', c.id, c.name, 'class');
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
title={c.name}
|
||||
style={{
|
||||
paddingLeft: '1.75rem',
|
||||
fontSize: '0.82rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left'
|
||||
}}
|
||||
>
|
||||
🏫 {c.code || c.name}
|
||||
<span className="nav-icon"><IconMyClass /></span>
|
||||
<span className="nav-label">{c.code || c.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="nav-header">Công tác sinh viên</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Công tác sinh viên</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('student-affairs')}
|
||||
>
|
||||
<span className="nav-icon"><IconStudentAffairs /></span>
|
||||
Công Tác Sinh Viên
|
||||
<span className="nav-label">Công tác sinh viên</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'applications' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('applications')}
|
||||
>
|
||||
<span className="nav-icon"><IconApplications /></span>
|
||||
Ứng Dụng
|
||||
<span className="nav-label">Ứng dụng</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(route.tab === 'rescide' && !inWorkspace)}
|
||||
onClick={() => setActiveTab('rescide')}
|
||||
>
|
||||
<span className="nav-icon"><IconRescide /></span>
|
||||
<span className="nav-label">RESCIDE IDE</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">Hệ thống</div>
|
||||
<li className="nav-section" aria-hidden="true">
|
||||
<span className="nav-header">Hệ thống</span>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(systemActive)}
|
||||
onClick={() => goSystem('organization')}
|
||||
>
|
||||
<span className="nav-icon"><IconSystem /></span>
|
||||
Quản lý hệ thống
|
||||
<span className="nav-label">Quản lý hệ thống</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className="nav-item nav-item--sub">
|
||||
<ul className="nav-sub">
|
||||
{SYSTEM_NAV.map(({ section }) => (
|
||||
<li key={section} className="nav-item">
|
||||
<li key={section}>
|
||||
<button
|
||||
type="button"
|
||||
className={navBtn(systemActive && route.systemSection === section)}
|
||||
onClick={() => goSystem(section)}
|
||||
>
|
||||
{SYSTEM_SECTION_LABELS[section]}
|
||||
<span className="nav-label">{SYSTEM_SECTION_LABELS[section]}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -338,12 +388,24 @@ function App() {
|
||||
onClick={() => setActiveTab('profile')}
|
||||
title="Tài khoản của tôi"
|
||||
>
|
||||
<span className="sidebar-user-avatar" aria-hidden>
|
||||
{(staff?.fullName?.trim() || staff?.email || '?')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(-2)
|
||||
.map((w) => w[0]?.toUpperCase() || '')
|
||||
.join('')
|
||||
.slice(0, 2) || '?'}
|
||||
</span>
|
||||
<span className="sidebar-user-meta">
|
||||
<span className="sidebar-user-name">
|
||||
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
|
||||
</span>
|
||||
<span className="sidebar-user-email">{staff?.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="link-btn" onClick={logout}>
|
||||
<button type="button" className="sidebar-logout" onClick={logout}>
|
||||
<IconLogout />
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
@@ -354,7 +416,7 @@ function App() {
|
||||
onClick={toggleSidebarCollapse}
|
||||
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
|
||||
>
|
||||
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor">
|
||||
<svg viewBox="0 0 10 18" width="10" height="18" fill="currentColor" aria-hidden>
|
||||
{sidebarCollapsed
|
||||
? <path d="M1 1 L9 9 L1 17" />
|
||||
: <path d="M9 1 L1 9 L9 17" />
|
||||
@@ -387,10 +449,12 @@ function App() {
|
||||
{route.tab === 'profile' && <MyAccountTab />}
|
||||
{route.tab === 'student-affairs' && <StudentAffairsTab />}
|
||||
{route.tab === 'applications' && <ApplicationsTab />}
|
||||
{route.tab === 'rescide' && <RescideTab />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<RescideAnnouncePopup disabled={inWorkspace} />
|
||||
<StaffChatSocket />
|
||||
<ChatWidget />
|
||||
</>
|
||||
|
||||
@@ -387,6 +387,7 @@ export interface StudentViolationItem {
|
||||
studentCode: string;
|
||||
fullName: string;
|
||||
classRkId: number;
|
||||
examRoomId?: number;
|
||||
kind: string;
|
||||
reason: string;
|
||||
monitorMode: string;
|
||||
@@ -396,7 +397,7 @@ export interface StudentViolationItem {
|
||||
|
||||
export const VIOLATION_KIND_OPTIONS = [
|
||||
{ value: '', label: 'Tất cả loại' },
|
||||
{ value: 'app_closed', label: 'Tự đóng app' },
|
||||
{ value: 'app_closed', label: 'Tắt ứng dụng' },
|
||||
{ value: 'unclean_shutdown', label: 'Tắt đột ngột' },
|
||||
{ value: 'multi_monitor', label: 'Nhiều màn hình' },
|
||||
{ value: 'user_switch', label: 'Đổi user' },
|
||||
@@ -932,6 +933,7 @@ export interface ExamRoomItem {
|
||||
studentCount: number;
|
||||
paperCount: number;
|
||||
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
||||
createdByStaffId?: number;
|
||||
}
|
||||
|
||||
export interface ExamPaperResource {
|
||||
@@ -984,8 +986,9 @@ function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
||||
}
|
||||
|
||||
export const apiExam = {
|
||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const res = await staffFetch('/exam-rooms');
|
||||
list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const q = opts?.mine ? '?mine=1' : '';
|
||||
const res = await staffFetch(`/exam-rooms${q}`);
|
||||
if (!res.ok) await parseError(res, 'Failed');
|
||||
return res.json();
|
||||
},
|
||||
@@ -1007,6 +1010,7 @@ export const apiExam = {
|
||||
canPublish: boolean;
|
||||
canUnpublish: boolean;
|
||||
canCancel: boolean;
|
||||
canExtend?: boolean;
|
||||
}>;
|
||||
},
|
||||
publish: async (id: number) => {
|
||||
@@ -1030,6 +1034,14 @@ export const apiExam = {
|
||||
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
||||
return res.json();
|
||||
},
|
||||
extend: async (id: number, minutes: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/extend`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ minutes }),
|
||||
});
|
||||
if (!res.ok) await parseError(res, 'Gia hạn thất bại');
|
||||
return res.json() as Promise<{ ok: boolean; endTime: string; addedMinutes: number; displayStatus: string }>;
|
||||
},
|
||||
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
|
||||
@@ -1124,6 +1136,14 @@ export const apiExam = {
|
||||
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
||||
return res.json() as Promise<{ assigned: number; packages: number }>;
|
||||
},
|
||||
assignPapersBatch: async (id: number, assignments: { studentRkId: number; paperId: number }[]) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/assign-papers-batch`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ assignments }),
|
||||
});
|
||||
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
||||
return res.json() as Promise<{ assigned: number }>;
|
||||
},
|
||||
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
|
||||
if (!res.ok) await parseError(res, 'Gửi gói đề thất bại');
|
||||
@@ -1186,6 +1206,64 @@ export const apiExam = {
|
||||
},
|
||||
};
|
||||
|
||||
export interface MyClassItem {
|
||||
rkId: number;
|
||||
name: string;
|
||||
classCode: string;
|
||||
}
|
||||
|
||||
export const apiMyClasses = {
|
||||
list: async (): Promise<MyClassItem[]> => {
|
||||
const res = await staffFetch('/staff/my-classes');
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json() as { data: MyClassItem[] };
|
||||
return json.data ?? [];
|
||||
},
|
||||
add: async (classRkId: number): Promise<void> => {
|
||||
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'POST' });
|
||||
},
|
||||
remove: async (classRkId: number): Promise<void> => {
|
||||
await staffFetch(`/staff/my-classes/${classRkId}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
export const apiSeatingLayout = {
|
||||
getClass: async (classRkId: number): Promise<string | null> => {
|
||||
const res = await staffFetch(`/classes/${classRkId}/seating-layout`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json() as { layoutJson: string | null };
|
||||
return json.layoutJson ?? null;
|
||||
},
|
||||
saveClass: async (classRkId: number, layoutJson: string): Promise<void> => {
|
||||
const res = await staffFetch(`/classes/${classRkId}/seating-layout`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ layoutJson }),
|
||||
});
|
||||
if (!res.ok) await parseError(res, 'Lưu sơ đồ lớp thất bại');
|
||||
},
|
||||
deleteClass: async (classRkId: number): Promise<void> => {
|
||||
const res = await staffFetch(`/classes/${classRkId}/seating-layout`, { method: 'DELETE' });
|
||||
if (!res.ok) await parseError(res, 'Xóa sơ đồ lớp thất bại');
|
||||
},
|
||||
getExam: async (examRoomId: number): Promise<string | null> => {
|
||||
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json() as { layoutJson: string | null };
|
||||
return json.layoutJson ?? null;
|
||||
},
|
||||
saveExam: async (examRoomId: number, layoutJson: string): Promise<void> => {
|
||||
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ layoutJson }),
|
||||
});
|
||||
if (!res.ok) await parseError(res, 'Lưu sơ đồ phòng thi thất bại');
|
||||
},
|
||||
deleteExam: async (examRoomId: number): Promise<void> => {
|
||||
const res = await staffFetch(`/exam-rooms/${examRoomId}/seating-layout`, { method: 'DELETE' });
|
||||
if (!res.ok) await parseError(res, 'Xóa sơ đồ phòng thi thất bại');
|
||||
},
|
||||
};
|
||||
|
||||
export const apiQldt = {
|
||||
getToken: async (): Promise<{ token: string }> => {
|
||||
const res = await staffFetch('/qldt-token');
|
||||
|
||||
@@ -9,6 +9,48 @@ interface AppPoolModalProps {
|
||||
allowedApps: string;
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconBox = ({ size = 20 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<path d="M3.27 6.96 12 12.01l8.73-5.05M12 22.08V12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 32 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatWhen = (iso: string) => {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
@@ -80,18 +122,29 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Kho ứng dụng</h2>
|
||||
<div className="modal-container app-pool-modal app-picker-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header app-picker-header">
|
||||
<div className="app-picker-header-text">
|
||||
<h2 className="modal-title app-picker-title">
|
||||
<span className="app-picker-title-icon" aria-hidden>
|
||||
<IconBox />
|
||||
</span>
|
||||
Kho ứng dụng
|
||||
</h2>
|
||||
<p className="app-pool-modal-sub">
|
||||
Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist lớp hiện tại.
|
||||
Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist hiện tại.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
|
||||
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<div className="app-picker-search-wrap">
|
||||
<span className="app-picker-search-icon" aria-hidden>
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
@@ -100,24 +153,32 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-secondary app-picker-refresh"
|
||||
onClick={() => loadPool(debouncedQ)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh />
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-body">
|
||||
{error ? (
|
||||
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
<div className="app-picker-state app-picker-state--error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
<div className="app-picker-state">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
<p>Đang tải kho ứng dụng...</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="app-pool-status">
|
||||
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'}
|
||||
<div className="app-picker-state">
|
||||
<IconInbox />
|
||||
<p>
|
||||
{debouncedQ ? `Không tìm thấy “${debouncedQ}”` : 'Chưa có app nào trong kho.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table app-pool-table">
|
||||
@@ -128,7 +189,7 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
<th>Tiêu đề</th>
|
||||
<th>Lần chặn</th>
|
||||
<th>Gần nhất</th>
|
||||
<th></th>
|
||||
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -136,17 +197,33 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
const added = allowedSet.has(app.keyword.toLowerCase());
|
||||
return (
|
||||
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}>
|
||||
<td><code className="app-pool-kw">{app.keyword}</code></td>
|
||||
<td className="app-pool-muted">{app.processName}</td>
|
||||
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>{app.windowTitle || '—'}</td>
|
||||
<td className="app-pool-hit">{app.hitCount}×</td>
|
||||
<td className="app-pool-muted">{formatWhen(app.lastSeenAt)}</td>
|
||||
<td>
|
||||
<code className="app-pool-kw">{app.keyword}</code>
|
||||
</td>
|
||||
<td className="app-pool-muted" title={app.processName}>
|
||||
{app.processName || '—'}
|
||||
</td>
|
||||
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>
|
||||
{app.windowTitle || '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className="app-pool-hit">{app.hitCount}×</span>
|
||||
</td>
|
||||
<td className="app-pool-muted app-pool-when">{formatWhen(app.lastSeenAt)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
{added ? (
|
||||
<span className="app-pool-added-tag">Đã có</span>
|
||||
<span className="app-pool-added-tag">
|
||||
<IconCheck />
|
||||
Đã có
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}>
|
||||
+ Thêm
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary app-pool-add-btn"
|
||||
onClick={() => handleSelect(app.keyword)}
|
||||
>
|
||||
<IconPlus />
|
||||
Thêm
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -159,7 +236,9 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="app-pool-footer">
|
||||
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải)
|
||||
Hiển thị <strong>{items.length}</strong> kết quả
|
||||
{debouncedQ ? ` cho “${debouncedQ}”` : ''}
|
||||
{' '}(tối đa 80 mỗi lần tải)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,47 @@ interface AppTemplatePickerModalProps {
|
||||
allowedApps: string;
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconLayers = ({ size = 20 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m12 2 9 4.5-9 4.5L3 6.5 12 2z" />
|
||||
<path d="m3 12 9 4.5 9-4.5" />
|
||||
<path d="m3 17.5 9 4.5 9-4.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 32 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function keywordList(csv: string): string[] {
|
||||
return csv.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
@@ -60,20 +101,29 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ứng dụng</h2>
|
||||
<div className="modal-container app-pool-modal app-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header app-picker-header">
|
||||
<div className="app-picker-header-text">
|
||||
<h2 className="modal-title app-picker-title">
|
||||
<span className="app-picker-title-icon" aria-hidden>
|
||||
<IconLayers />
|
||||
</span>
|
||||
Chọn khung ứng dụng
|
||||
</h2>
|
||||
<p className="app-pool-modal-sub">
|
||||
Ghép bộ keyword đã lưu vào danh sách ứng dụng được phép. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||
Ghép bộ keyword đã lưu vào whitelist. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<div className="app-picker-search-wrap">
|
||||
<span className="app-picker-search-icon" aria-hidden>
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
@@ -82,30 +132,58 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary app-picker-refresh"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh />
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-body">
|
||||
{error ? (
|
||||
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
<div className="app-picker-state app-picker-state--error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
<div className="app-picker-state">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
<p>Đang tải khung ứng dụng...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="app-pool-status">
|
||||
{q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
||||
<div className="app-picker-state">
|
||||
<IconInbox />
|
||||
<p>
|
||||
{q
|
||||
? `Không tìm thấy “${search}”`
|
||||
: 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-picker-list">
|
||||
{filtered.map((tpl) => (
|
||||
<div key={tpl.id} className="template-picker-card">
|
||||
{filtered.map((tpl) => {
|
||||
const kws = keywordList(tpl.keywords);
|
||||
const count = countKeywords(tpl.keywords);
|
||||
return (
|
||||
<article key={tpl.id} className="template-picker-card">
|
||||
<div className="template-picker-card-main">
|
||||
<div className="template-picker-card-head">
|
||||
<strong>{tpl.name}</strong>
|
||||
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
|
||||
<strong className="template-picker-name">{tpl.name}</strong>
|
||||
<span className="template-picker-count">{count} keyword</span>
|
||||
</div>
|
||||
{tpl.description ? (
|
||||
<p className="template-picker-desc">{tpl.description}</p>
|
||||
) : null}
|
||||
<div className="template-picker-chips" title={tpl.keywords}>
|
||||
{kws.map((kw) => (
|
||||
<span key={kw} className="template-picker-chip">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
|
||||
<code className="template-picker-kw">{tpl.keywords}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm template-picker-apply"
|
||||
@@ -114,16 +192,19 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Áp dụng khung
|
||||
<IconCheck size={13} />
|
||||
Áp dụng
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-pool-footer">
|
||||
Đang có {countKeywords(allowedApps)} keyword trong whitelist hiện tại
|
||||
Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
|
||||
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,101 @@ import {
|
||||
type LeaveRequestItem,
|
||||
} from '../api';
|
||||
|
||||
/* ─── Icon components ─────────────────────────────────────────────────────── */
|
||||
|
||||
type IconProps = { size?: number; className?: string };
|
||||
|
||||
const IconClipboard = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronDown = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronUp = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMail = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconUpload = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInfo = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconXMark = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconImage = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSync = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 12c0-4.42 3.58-8 8-8 2.21 0 4.21.9 5.66 2.34" />
|
||||
<polyline points="14 8 20 8 20 2" />
|
||||
<path d="M20 12c0 4.42-3.58 8-8 8-2.21 0-4.21-.9-5.66-2.34" />
|
||||
<polyline points="10 16 4 16 4 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ─── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
interface AttendancePanelProps {
|
||||
classId: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function formatQldtTime(iso?: string): string {
|
||||
@@ -28,7 +121,9 @@ function formatQldtTime(iso?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) => {
|
||||
/* ─── Component ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId, onClose }) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [date, setDate] = useState(today);
|
||||
const [period, setPeriod] = useState(1);
|
||||
@@ -46,11 +141,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollTop = e.currentTarget.scrollTop;
|
||||
if (scrollTop > 20) {
|
||||
setIsCollapsed(true);
|
||||
} else if (scrollTop <= 5) {
|
||||
setIsCollapsed(false);
|
||||
}
|
||||
setIsCollapsed(scrollTop > 20);
|
||||
}, []);
|
||||
|
||||
const loadShifts = useCallback(async () => {
|
||||
@@ -60,9 +151,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
|
||||
setPeriod(res.data[0].period || 1);
|
||||
}
|
||||
} catch {
|
||||
setShifts([]);
|
||||
}
|
||||
} catch { setShifts([]); }
|
||||
}, [classId, date, period]);
|
||||
|
||||
const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
|
||||
@@ -72,7 +161,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const res = await apiFetchAttendance(classId, date, period);
|
||||
setRows(res.data || []);
|
||||
setShiftInfo(res.shift || null);
|
||||
if (!isBg) setSelectedStudentRkIds([]); // Clear selection when data changes
|
||||
if (!isBg) setSelectedStudentRkIds([]);
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Không tải được điểm danh');
|
||||
} finally {
|
||||
@@ -80,26 +169,16 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
}
|
||||
}, [classId, date, period]);
|
||||
|
||||
const currentShift = useMemo(() => {
|
||||
return shifts.find(s => s.period === period);
|
||||
}, [shifts, period]);
|
||||
|
||||
const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]);
|
||||
const courseId = currentShift?.courseId;
|
||||
|
||||
const loadLeaveRequests = useCallback(async () => {
|
||||
if (!courseId) {
|
||||
setLeaveRequests([]);
|
||||
return;
|
||||
}
|
||||
if (!courseId) { setLeaveRequests([]); return; }
|
||||
try {
|
||||
setLoadingLeave(true);
|
||||
const res = await apiFetchLeaveRequests(classId, courseId, date);
|
||||
setLeaveRequests(res || []);
|
||||
} catch {
|
||||
setLeaveRequests([]);
|
||||
} finally {
|
||||
setLoadingLeave(false);
|
||||
}
|
||||
} catch { setLeaveRequests([]); } finally { setLoadingLeave(false); }
|
||||
}, [classId, courseId, date]);
|
||||
|
||||
useEffect(() => { loadShifts(); }, [loadShifts]);
|
||||
@@ -108,12 +187,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
|
||||
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
|
||||
try {
|
||||
await apiUpdateLeaveStatus(classId, leaveId, {
|
||||
status,
|
||||
studentRkId,
|
||||
date,
|
||||
period,
|
||||
});
|
||||
await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period });
|
||||
await loadAttendance();
|
||||
await loadLeaveRequests();
|
||||
} catch (e: any) {
|
||||
@@ -125,87 +199,61 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(row => {
|
||||
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel].filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [rows, searchQuery]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
for (const row of rows) {
|
||||
if (counts[row.status] !== undefined) counts[row.status]++;
|
||||
}
|
||||
for (const row of rows) { if (counts[row.status] !== undefined) counts[row.status]++; }
|
||||
return counts;
|
||||
}, [rows]);
|
||||
|
||||
const toggleStudentSelection = useCallback((studentRkId: number) => {
|
||||
const toggleStudentSelection = (studentRkId: number) => {
|
||||
setSelectedStudentRkIds(prev =>
|
||||
prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId]
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (studentRkId: number, status: number) => {
|
||||
// Optimistic state update so the UI reacts instantly
|
||||
setRows(prevRows =>
|
||||
prevRows.map(row => {
|
||||
if (row.studentRkId === studentRkId) {
|
||||
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
|
||||
return {
|
||||
...row,
|
||||
status,
|
||||
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
|
||||
statusEditedByTeacher: true,
|
||||
};
|
||||
return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
|
||||
}
|
||||
return row;
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
|
||||
await loadAttendance(true);
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Cập nhật trạng thái thất bại');
|
||||
await loadAttendance(); // Rollback on failure
|
||||
await loadAttendance();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkStatusChange = async (status: number) => {
|
||||
if (selectedStudentRkIds.length === 0) return;
|
||||
|
||||
// Optimistic state update for selected students
|
||||
setRows(prevRows =>
|
||||
prevRows.map(row => {
|
||||
if (selectedStudentRkIds.includes(row.studentRkId)) {
|
||||
const matchedOpt = ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status);
|
||||
return {
|
||||
...row,
|
||||
status,
|
||||
statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel,
|
||||
statusEditedByTeacher: true,
|
||||
};
|
||||
return { ...row, status, statusLabel: matchedOpt ? matchedOpt.label : row.statusLabel, statusEditedByTeacher: true };
|
||||
}
|
||||
return row;
|
||||
})
|
||||
);
|
||||
|
||||
const idsToUpdate = [...selectedStudentRkIds];
|
||||
setSelectedStudentRkIds([]);
|
||||
|
||||
try {
|
||||
await apiUpdateAttendanceBulkStatus(classId, {
|
||||
date,
|
||||
period,
|
||||
studentRkIds: idsToUpdate,
|
||||
status,
|
||||
});
|
||||
await apiUpdateAttendanceBulkStatus(classId, { date, period, studentRkIds: idsToUpdate, status });
|
||||
await loadAttendance(true);
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Cập nhật hàng loạt thất bại');
|
||||
await loadAttendance(); // Rollback
|
||||
await loadAttendance();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,179 +270,257 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
await loadAttendance();
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Đẩy QLĐT thất bại');
|
||||
} finally {
|
||||
setPushing(false);
|
||||
}
|
||||
} finally { setPushing(false); }
|
||||
};
|
||||
|
||||
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
|
||||
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
||||
|
||||
const hasPendingLeave = leaveRequests.some(r => r.status === 'Đang chờ');
|
||||
|
||||
return (
|
||||
<div className="attendance-panel">
|
||||
<div className="attendance-toolbar">
|
||||
<label className="attendance-field">
|
||||
<span>Ngày</span>
|
||||
<input
|
||||
type="date"
|
||||
className="search-input"
|
||||
style={{ padding: '0.5rem 0.75rem' }}
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="attendance-field">
|
||||
<span>Ca học</span>
|
||||
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
||||
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
|
||||
<option key={s.period} value={s.period}>
|
||||
Ca {s.period}{s.startTime ? ` (${s.startTime}–${s.endTime})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="attendance-field attendance-field--grow">
|
||||
<span>Tìm sinh viên</span>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search attendance-search"
|
||||
placeholder="Mã SV, tên, email..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="ap-panel">
|
||||
<style>{`
|
||||
.ap-panel { display: flex; flex-direction: column; height: 100%; gap: 0.65rem; }
|
||||
|
||||
/* Header */
|
||||
.ap-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.ap-header-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
|
||||
.ap-header-title svg { color: var(--accent); flex-shrink: 0; }
|
||||
.ap-header-actions { display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
/* Buttons */
|
||||
.ap-btn { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.25rem 0.6rem; font-size: 0.75rem; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); cursor: pointer; transition: background 0.15s, border-color 0.15s; line-height: 1.4; white-space: nowrap; }
|
||||
.ap-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
|
||||
.ap-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.ap-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.ap-btn--primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
|
||||
.ap-btn--close { padding: 0.22rem 0.45rem; }
|
||||
.ap-btn--leave-badge { background: var(--accent); color: #fff; font-size: 0.62rem; font-weight: 700; padding: 0.05rem 0.3rem; border-radius: 10px; line-height: 1.4; }
|
||||
.ap-btn--leave-badge.muted { background: var(--text-secondary); }
|
||||
|
||||
/* Collapsible toolbar area */
|
||||
.ap-collapsible { display: flex; flex-direction: column; gap: 0.5rem; overflow: hidden; transition: max-height 0.3s ease; }
|
||||
.ap-collapsible.collapsed { max-height: 0 !important; }
|
||||
|
||||
/* Toolbar */
|
||||
.ap-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
|
||||
.ap-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
|
||||
.ap-field--grow { flex: 1; }
|
||||
.ap-input { padding: 0.3rem 0.5rem; font-size: 0.78rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-primary); }
|
||||
.ap-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* QLĐT sync banner */
|
||||
.ap-qldt-banner { padding: 0.45rem 0.7rem; border-radius: var(--radius-sm); background: var(--bg-subtle); border: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 0.1rem; font-size: 0.77rem; }
|
||||
.ap-qldt-banner--synced { background: rgba(46,125,50,0.06); border-color: rgba(46,125,50,0.3); color: #2e7d32; }
|
||||
.ap-qldt-banner--stale { background: rgba(180,83,9,0.06); border-color: rgba(180,83,9,0.35); color: #92400e; }
|
||||
|
||||
/* Shift info strip */
|
||||
.ap-shift-info { display: flex; flex-wrap: wrap; gap: 0.5rem; font-size: 0.77rem; color: var(--text-secondary); padding: 0.3rem 0.6rem; background: var(--bg-subtle); border-radius: var(--radius-sm); border: 1px solid var(--border-color); }
|
||||
|
||||
/* Status chips */
|
||||
.ap-chips { display: flex; flex-wrap: wrap; gap: 0.22rem; align-items: center; }
|
||||
.ap-chip { border: 1px solid transparent; border-radius: 99px; padding: 0.14rem 0.4rem; font-size: 0.68rem; font-weight: 700; cursor: default; background: transparent; display: inline-flex; align-items: center; gap: 0.18rem; }
|
||||
.ap-chip-count { min-width: 1rem; text-align: center; padding: 0.04rem 0.18rem; border-radius: 99px; background: rgba(0,0,0,0.12); }
|
||||
.ap-chips-total { margin-left: auto; font-size: 0.71rem; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* Notice banner */
|
||||
.ap-notice { padding: 0.55rem 0.75rem; border: 1px solid var(--border-color); border-left: 3px solid var(--accent); border-radius: var(--radius-sm); background: var(--bg-subtle); font-size: 0.75rem; color: var(--text-secondary); line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.ap-notice svg { color: var(--accent); flex-shrink: 0; margin-top: 0.05rem; }
|
||||
.ap-notice--amber { border-left-color: #d97706; background: rgba(253,230,138,0.18); color: #78350f; }
|
||||
.ap-notice--amber svg { color: #d97706; }
|
||||
|
||||
/* Bulk actions bar */
|
||||
.ap-bulk { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-sm); flex-wrap: wrap; }
|
||||
.ap-bulk-title { font-weight: 600; font-size: 0.82rem; }
|
||||
.ap-bulk-btns { display: flex; gap: 0.2rem; flex-wrap: wrap; }
|
||||
|
||||
/* Table */
|
||||
.ap-table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
|
||||
.ap-table { width: 100%; border-collapse: collapse; }
|
||||
.ap-table thead th { padding: 0.5rem 0.8rem; background: var(--bg-subtle); font-weight: 700; font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
.ap-table thead th:first-child { width: 40px; text-align: center; padding: 0.5rem 0.5rem; }
|
||||
.ap-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.1s; }
|
||||
.ap-table tbody tr:last-child { border-bottom: none; }
|
||||
.ap-table tbody td { padding: 0.42rem 0.8rem; font-size: 0.82rem; vertical-align: middle; }
|
||||
.ap-table tbody td:first-child { text-align: center; padding: 0.42rem 0.5rem; }
|
||||
|
||||
/* QLĐT inline tags */
|
||||
.ap-tag { font-size: 0.62rem; font-weight: 700; padding: 0.1rem 0.3rem; border-radius: 3px; }
|
||||
.ap-tag--ok { background: rgba(46,125,50,0.1); color: #2e7d32; border: 1px solid rgba(46,125,50,0.3); }
|
||||
.ap-tag--pending { background: var(--bg-subtle); color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.ap-tag--locked { background: rgba(21,101,192,0.08); color: #1565c0; border: 1px solid rgba(21,101,192,0.25); }
|
||||
|
||||
/* Leave modal notice */
|
||||
.ap-leave-notice { padding: 0.55rem 0.75rem; border: 1px solid #bae6fd; border-left: 3px solid #0ea5e9; border-radius: var(--radius-sm); background: rgba(224,242,254,0.5); font-size: 0.76rem; color: #0369a1; line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.ap-leave-notice svg { color: #0ea5e9; flex-shrink: 0; margin-top: 0.05rem; }
|
||||
.ap-leave-card { display: flex; flex-direction: column; gap: 8px; padding: 0.75rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); }
|
||||
.ap-leave-card-reason { font-size: 0.77rem; color: var(--text-primary); background: var(--bg-subtle); padding: 0.4rem 0.65rem; border-radius: var(--radius-sm); border-left: 3px solid #cbd5e1; line-height: 1.45; }
|
||||
.ap-leave-status { font-size: 0.69rem; font-weight: 600; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||
.ap-leave-status--pending { background: #fef3c7; color: #b45309; }
|
||||
.ap-leave-status--approved { background: #d1fae5; color: #065f46; }
|
||||
.ap-leave-status--rejected { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
/* Empty & loading states */
|
||||
.ap-empty { min-height: 200px; display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 0.85rem; }
|
||||
`}</style>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="ap-header">
|
||||
<h3 className="ap-header-title">
|
||||
<IconClipboard size={18} />
|
||||
Điểm danh ca học
|
||||
</h3>
|
||||
<div className="ap-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="ap-btn"
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
title={isCollapsed ? 'Hiện đầy đủ thông tin chi tiết' : 'Ẩn bớt thông tin chi tiết để xem bảng to hơn'}
|
||||
>
|
||||
{isCollapsed ? '📂 Chi tiết' : '📁 Thu gọn'}
|
||||
{isCollapsed ? <><IconChevronDown size={14} /> Chi tiết</> : <><IconChevronUp size={14} /> Thu gọn</>}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => loadAttendance(false)} disabled={loading}>
|
||||
<button
|
||||
type="button"
|
||||
className="ap-btn"
|
||||
onClick={() => loadAttendance(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
Tải lại
|
||||
</button>
|
||||
{courseId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="ap-btn"
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
✉️ Đơn phép
|
||||
<IconMail size={14} />
|
||||
Đơn phép
|
||||
{leaveRequests.length > 0 && (
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
background: leaveRequests.some(r => r.status === 'Đang chờ') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
color: '#fff',
|
||||
fontSize: '0.72rem',
|
||||
padding: '0.1rem 0.35rem',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
<span className={`ap-btn--leave-badge${hasPendingLeave ? '' : ' muted'}`}>
|
||||
{leaveRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
|
||||
<button
|
||||
type="button"
|
||||
className="ap-btn ap-btn--primary"
|
||||
onClick={handlePushQLDT}
|
||||
disabled={pushing || rows.length === 0}
|
||||
>
|
||||
<IconUpload size={14} />
|
||||
{pushing ? 'Đang tải...' : 'Đẩy QLĐT'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`attendance-collapsible-wrapper ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<div
|
||||
className={`attendance-qldt-banner${
|
||||
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : ''
|
||||
}`}
|
||||
>
|
||||
{qldtSynced ? (
|
||||
qldtDirty ? (
|
||||
<>
|
||||
<strong>Đã lưu QLĐT — có thay đổi mới</strong>
|
||||
<span>
|
||||
Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại sau khi chỉnh sửa
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Đã lưu QLĐT</strong>
|
||||
<span>Lần đẩy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<strong>Chưa đẩy lên QLĐT</strong>
|
||||
<span>Ca {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra trạng thái</span>
|
||||
</>
|
||||
{onClose && (
|
||||
<button type="button" className="ap-btn ap-btn--close" onClick={onClose} title="Đóng">
|
||||
<IconClose size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Collapsible toolbar area ── */}
|
||||
<div className={`ap-collapsible${isCollapsed ? ' collapsed' : ''}`}>
|
||||
{/* Toolbar: date / shift / search */}
|
||||
<div className="ap-toolbar">
|
||||
<label className="ap-field">
|
||||
<span>Ngày</span>
|
||||
<input type="date" className="ap-input search-input" value={date} onChange={e => setDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="ap-field">
|
||||
<span>Ca học</span>
|
||||
<select className="ap-input select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
||||
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
|
||||
<option key={s.period} value={s.period}>Ca {s.period}{s.startTime ? ` (${s.startTime}–${s.endTime})` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="ap-field ap-field--grow">
|
||||
<span>Tìm sinh viên</span>
|
||||
<input type="search" className="ap-input app-pool-search attendance-search" placeholder="Mã SV, tên, email..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* QLĐT sync status banner */}
|
||||
<div className={`ap-qldt-banner${qldtSynced ? (qldtDirty ? ' ap-qldt-banner--stale' : ' ap-qldt-banner--synced') : ''}`}>
|
||||
{qldtSynced
|
||||
? qldtDirty
|
||||
? <><strong>Đã lưu QLĐT — có thay đổi mới</strong><span>Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại</span></>
|
||||
: <><strong>Đã lưu QLĐT</strong><span>Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span></>
|
||||
: <><strong>Chưa đẩy lên QLĐT</strong><span>Ca {period} ngày {date} — bấm "Đẩy QLĐT" sau khi kiểm tra</span></>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Shift info strip */}
|
||||
{currentShift && (
|
||||
<div className="attendance-shift-info">
|
||||
<div className="ap-shift-info">
|
||||
<span>{currentShift.startTime}–{currentShift.endTime}</span>
|
||||
<span>{currentShift.courseName || 'Chưa chọn môn'}</span>
|
||||
{!currentShift.isActive && <span className="badge badge-muted">Ca tắt</span>}
|
||||
{!currentShift.isActive && <span className="badge badge-muted" style={{ fontSize: '0.65rem' }}>Ca tắt</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="attendance-status-summary">
|
||||
{/* Status summary chips */}
|
||||
<div className="ap-chips">
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`attendance-summary-chip ${attendanceStatusClass(opt.value)}`}
|
||||
onClick={() => setSearchQuery('')}
|
||||
title={`${opt.label}: ${statusCounts[opt.value] ?? 0} sinh viên`}
|
||||
>
|
||||
<span className="attendance-summary-chip-label">{opt.short}</span>
|
||||
<span className="attendance-summary-chip-count">{statusCounts[opt.value] ?? 0}</span>
|
||||
<button key={opt.value} type="button" className={`ap-chip attendance-summary-chip ${attendanceStatusClass(opt.value)}`} onClick={() => setSearchQuery('')}>
|
||||
<span>{opt.short}</span>
|
||||
<span className="ap-chip-count">{statusCounts[opt.value] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="attendance-summary-total">
|
||||
{filteredRows.length}/{rows.length} SV
|
||||
{searchQuery.trim() ? ' (đã lọc)' : ''}
|
||||
</span>
|
||||
<span className="ap-chips-total">{filteredRows.length}/{rows.length} SV{searchQuery.trim() ? ' (đã lọc)' : ''}</span>
|
||||
</div>
|
||||
|
||||
<p className="schedule-hint" style={{ margin: '0 0 4px 0' }}>
|
||||
<p className="schedule-hint" style={{ margin: '0 0 2px 0', fontSize: '0.71rem', color: 'var(--text-muted)' }}>
|
||||
Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.
|
||||
</p>
|
||||
|
||||
<div style={{ padding: '0.65rem 0.85rem', background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '4px', color: '#92400e', fontSize: '0.82rem', display: 'flex', alignItems: 'center', gap: '0.35rem', margin: '4px 0 4px 0', lineHeight: '1.4' }}>
|
||||
💡 <strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và <strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên, sau đó bấm nút <strong>Đẩy QLĐT</strong> ở góc phải bên trên để đồng bộ điểm danh chính thức.
|
||||
{/* Notice banner — replaces yellow emoji block */}
|
||||
<div className="ap-notice ap-notice--amber">
|
||||
<IconInfo size={15} />
|
||||
<span>
|
||||
<strong>Lưu ý:</strong> Hệ thống hiện tại chỉ ghi nhận điểm danh học tập nội bộ và{' '}
|
||||
<strong>KHÔNG tự động lưu lên QLĐT</strong>. Thầy cô vui lòng kiểm tra kỹ trạng thái của sinh viên,
|
||||
sau đó bấm nút <strong>Đẩy QLĐT</strong> ở góc phải bên trên để đồng bộ điểm danh chính thức.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leave Requests Approval Modal */}
|
||||
{/* ── Leave Requests Modal ── */}
|
||||
{showLeaveModal && courseId && (
|
||||
<div className="modal-overlay" onClick={() => setShowLeaveModal(false)}>
|
||||
<div className="modal-overlay" style={{ zIndex: 200 }} onClick={() => setShowLeaveModal(false)}>
|
||||
<div className="modal-container" style={{ maxWidth: '680px', width: '95%', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 className="modal-title" style={{ margin: 0 }}>✉️ Đơn xin nghỉ phép trong ca</h3>
|
||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>
|
||||
Ca {period} ngày {date} - Giao diện duyệt đơn xin nghỉ từ cổng QLĐT portal.
|
||||
</p>
|
||||
<h3 className="modal-title" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<IconMail size={17} />
|
||||
Đơn xin nghỉ phép
|
||||
</h3>
|
||||
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.8rem' }}>Ca {period} ngày {date}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={loadLeaveRequests}
|
||||
disabled={loadingLeave}
|
||||
style={{ padding: '0.3rem 0.6rem', fontSize: '0.78rem' }}
|
||||
>
|
||||
{loadingLeave ? 'Đang tải...' : '🔄 Làm mới'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setShowLeaveModal(false)}>
|
||||
Đóng
|
||||
<button type="button" className="ap-btn" onClick={loadLeaveRequests} disabled={loadingLeave}>
|
||||
<IconRefresh size={14} />
|
||||
{loadingLeave ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
<button type="button" className="ap-btn" onClick={() => setShowLeaveModal(false)}>Đóng</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1rem', overflowY: 'auto', maxHeight: '60vh', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{/* Info notice */}
|
||||
<div className="ap-leave-notice">
|
||||
<IconInfo size={15} />
|
||||
<div>
|
||||
<strong>Lưu ý duyệt đơn phép:</strong> Thao tác duyệt/từ chối đơn tại đây chỉ được ghi nhận{' '}
|
||||
<strong>nội bộ trên hệ thống Simple Care</strong> (không đồng bộ ngược lên QLĐT).
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
• Khi duyệt đơn, hệ thống sẽ tự động đổi trạng thái điểm danh thành <strong>"Nghỉ có phép"</strong>.<br />
|
||||
• Trạng thái này sẽ được cập nhật lên QLĐT khi bấm nút <strong>"Đẩy QLĐT"</strong>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingLeave ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
@@ -405,81 +531,43 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</div>
|
||||
) : (
|
||||
leaveRequests.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '0.85rem',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: 'var(--shadow-sm)'
|
||||
}}
|
||||
>
|
||||
<div key={req.id} className="ap-leave-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{req.student.fullName}</div>
|
||||
<code style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{req.student.studentCode}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: req.status === 'Đang chờ' ? '#fef3c7' : req.status === 'Phê duyệt' ? '#d1fae5' : '#fee2e2',
|
||||
color: req.status === 'Đang chờ' ? '#b45309' : req.status === 'Phê duyệt' ? '#065f46' : '#991b1b',
|
||||
padding: '0.15rem 0.45rem',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<span className={`ap-leave-status ${req.status === 'Đang chờ' ? 'ap-leave-status--pending' : req.status === 'Phê duyệt' ? 'ap-leave-status--approved' : 'ap-leave-status--rejected'}`}>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{req.status === 'Đang chờ' && (
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', backgroundColor: 'var(--success)', borderColor: 'var(--success)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}? Trạng thái điểm danh ca sẽ chuyển thành Nghỉ có phép.`)) {
|
||||
handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id);
|
||||
}
|
||||
}}
|
||||
className="ap-btn ap-btn--primary"
|
||||
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||
onClick={() => { if (confirm(`Phê duyệt đơn xin nghỉ phép của ${req.student.fullName}?`)) handleUpdateLeaveStatus(req.id, 'Phê duyệt', req.student.id); }}
|
||||
>
|
||||
✓ Phê duyệt
|
||||
<IconCheck size={13} /> Phê duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm text-danger"
|
||||
style={{ padding: '0.25rem 0.65rem', fontSize: '0.78rem', borderColor: '#fca5a5' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) {
|
||||
handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id);
|
||||
}
|
||||
}}
|
||||
className="ap-btn"
|
||||
style={{ borderColor: '#fca5a5' }}
|
||||
onClick={() => { if (confirm(`Từ chối đơn xin nghỉ phép của ${req.student.fullName}?`)) handleUpdateLeaveStatus(req.id, 'Từ chối', req.student.id); }}
|
||||
>
|
||||
✗ Từ chối
|
||||
<IconXMark size={13} /> Từ chối
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-primary)', background: '#f9fafb', padding: '8px 10px', borderRadius: '4px', borderLeft: '4px solid #cbd5e1', lineHeight: '1.4' }}>
|
||||
<div className="ap-leave-card-reason">
|
||||
<strong>Lý do nghỉ:</strong> {req.note || 'Không có ghi chú'}
|
||||
</div>
|
||||
|
||||
{req.reasonImage && (
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<a
|
||||
href={req.reasonImage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.75rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px', fontWeight: 500 }}
|
||||
>
|
||||
🖼️ Xem ảnh minh chứng phép
|
||||
<div>
|
||||
<a href={req.reasonImage} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.72rem', color: 'var(--accent)', textDecoration: 'underline', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<IconImage size={13} /> Xem ảnh minh chứng
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -491,43 +579,34 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Bulk status actions bar ── */}
|
||||
{selectedStudentRkIds.length > 0 && (
|
||||
<div className="attendance-bulk-actions">
|
||||
<span className="attendance-bulk-title">
|
||||
Đang chọn {selectedStudentRkIds.length} sinh viên:
|
||||
</span>
|
||||
<div className="attendance-bulk-btns">
|
||||
<div className="ap-bulk">
|
||||
<span className="ap-bulk-title">Đang chọn {selectedStudentRkIds.length} SV:</span>
|
||||
<div className="ap-bulk-btns">
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`btn btn-secondary ${attendanceStatusClass(opt.value)}`}
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--att-border)' }}
|
||||
onClick={() => handleBulkStatusChange(opt.value)}
|
||||
>
|
||||
Gắn "{opt.label}"
|
||||
<button key={opt.value} type="button" className={`ap-btn ${attendanceStatusClass(opt.value)}`} style={{ border: '1px solid var(--att-border)' }} onClick={() => handleBulkStatusChange(opt.value)}>
|
||||
Gắn "{opt.label}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-muted attendance-bulk-btn-close"
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.875rem' }}
|
||||
onClick={() => setSelectedStudentRkIds([])}
|
||||
>
|
||||
<button type="button" className="ap-btn" style={{ marginLeft: 'auto' }} onClick={() => setSelectedStudentRkIds([])}>
|
||||
Hủy chọn
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="attendance-table-scroll table-wrapper" onScroll={handleScroll}>
|
||||
{/* ── Attendance table ── */}
|
||||
<div className="ap-table-scroll attendance-table-scroll table-wrapper" onScroll={handleScroll}>
|
||||
{loading ? (
|
||||
<div className="empty-state"><div className="sync-spinner" style={{ width: 28, height: 28 }} /></div>
|
||||
<div className="ap-empty">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table attendance-table">
|
||||
<table className="ap-table data-table attendance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45px', textAlign: 'center' }}>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))}
|
||||
@@ -552,7 +631,7 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '1.5rem 0', fontStyle: 'italic', fontSize: '0.83rem' }}>
|
||||
{rows.length === 0 ? 'Chưa có dữ liệu điểm danh' : 'Không tìm thấy sinh viên phù hợp'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -560,60 +639,38 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
||||
filteredRows.map(row => {
|
||||
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
|
||||
return (
|
||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
|
||||
<td
|
||||
style={{ textAlign: 'center', cursor: 'pointer' }}
|
||||
onClick={() => toggleStudentSelection(row.studentRkId)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleStudentSelection(row.studentRkId);
|
||||
}}
|
||||
/>
|
||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)} style={{ background: isSelected ? 'rgba(var(--accent-rgb, 99 102 241) / 0.05)' : undefined }}>
|
||||
<td>
|
||||
<input type="checkbox" checked={isSelected} onChange={() => toggleStudentSelection(row.studentRkId)} />
|
||||
</td>
|
||||
<td
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => toggleStudentSelection(row.studentRkId)}
|
||||
>
|
||||
<div className="attendance-student-name">{row.fullName}</div>
|
||||
<div className="attendance-student-meta">
|
||||
<td style={{ cursor: 'pointer' }} onClick={() => toggleStudentSelection(row.studentRkId)}>
|
||||
<div className="attendance-student-name" style={{ fontWeight: 700, fontSize: '0.85rem' }}>{row.fullName}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '1px' }}>
|
||||
<code>{row.studentCode}</code>
|
||||
{row.email && <span>{row.email}</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="attendance-online-mins">{row.onlineMinutes}</span>
|
||||
<span className="attendance-online-unit">phút</span>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: '0.95rem' }}>{row.onlineMinutes}</span>
|
||||
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginLeft: '2px' }}>phút</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
|
||||
style={{ padding: '0.2rem 0.4rem', fontSize: '0.72rem', fontWeight: 600, border: '1px solid var(--att-border)', background: 'var(--att-bg)', color: 'var(--att-fg)', borderRadius: '4px', cursor: 'pointer' }}
|
||||
value={row.status}
|
||||
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
|
||||
>
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div className="attendance-notes">
|
||||
{row.pushedToQldtAt ? (
|
||||
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}>
|
||||
QLĐT ✓
|
||||
</span>
|
||||
) : (
|
||||
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span>
|
||||
)}
|
||||
{row.statusEditedByTeacher && (
|
||||
<span className="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè">
|
||||
Đã khóa
|
||||
</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||||
{row.pushedToQldtAt
|
||||
? <span className="ap-tag ap-tag--ok">QLĐT <IconSync size={10} /></span>
|
||||
: <span className="ap-tag ap-tag--pending">Chưa QLĐT</span>
|
||||
}
|
||||
{row.statusEditedByTeacher && <span className="ap-tag ap-tag--locked">Đã khóa</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,85 @@ import { api } from '../api';
|
||||
import type { ClassItem, SyncStatus } from '../api';
|
||||
import { openClass } from './NavHistoryBar';
|
||||
|
||||
type IconProps = { size?: number; className?: string };
|
||||
|
||||
const IconGraduation = ({ size = 22 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 1 2.5 3 6 3s6-2 6-3v-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconAlert = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconUsers = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSpecialize = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconOpen = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 6 6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 40 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronLeft = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronRight = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ClassesTab: React.FC = () => {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -12,7 +91,6 @@ export const ClassesTab: React.FC = () => {
|
||||
const [systemFilter, setSystemFilter] = useState<number | undefined>(undefined);
|
||||
const [studyingOnly, setStudyingOnly] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
const fetchClasses = async () => {
|
||||
@@ -49,12 +127,10 @@ export const ClassesTab: React.FC = () => {
|
||||
fetchClasses();
|
||||
}, [page, search, systemFilter, studyingOnly]);
|
||||
|
||||
// Check sync status on load and start polling if running
|
||||
useEffect(() => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
@@ -62,7 +138,7 @@ export const ClassesTab: React.FC = () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchClasses(); // Reload data when sync completes
|
||||
fetchClasses();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -74,13 +150,12 @@ export const ClassesTab: React.FC = () => {
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startClassesSync();
|
||||
// Set local state to running to trigger useEffect poller
|
||||
setSyncStatus({
|
||||
running: true,
|
||||
done: false,
|
||||
total: 0,
|
||||
synced: 0,
|
||||
updatedAt: Date.now() / 1000
|
||||
updatedAt: Date.now() / 1000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Không thể bắt đầu đồng bộ lớp học');
|
||||
@@ -90,76 +165,83 @@ export const ClassesTab: React.FC = () => {
|
||||
const handleToggleStudying = async (cItem: ClassItem) => {
|
||||
const nextVal = !cItem.isStudying;
|
||||
try {
|
||||
// Optimistic update
|
||||
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c));
|
||||
setClasses(prev =>
|
||||
prev.map(c => (c.rkId === cItem.rkId ? { ...c, isStudying: nextVal } : c))
|
||||
);
|
||||
await api.updateClassStudying(cItem.rkId, nextVal);
|
||||
} catch (err: any) {
|
||||
// Revert if error
|
||||
setClasses(prev => prev.map(c => c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c));
|
||||
setClasses(prev =>
|
||||
prev.map(c => (c.rkId === cItem.rkId ? { ...c, isStudying: !nextVal } : c))
|
||||
);
|
||||
alert(err.message || 'Không thể cập nhật trạng thái lớp học');
|
||||
}
|
||||
};
|
||||
|
||||
// Tính toán % tiến trình sync
|
||||
const syncPercent = syncStatus && syncStatus.total > 0
|
||||
const syncPercent =
|
||||
syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Quản Lý Lớp Học</h1>
|
||||
<h1 className="page-title-heading">
|
||||
<span className="page-title-icon" aria-hidden>
|
||||
<IconGraduation />
|
||||
</span>
|
||||
Quản lý lớp học
|
||||
</h1>
|
||||
<p>Đồng bộ dữ liệu lớp học và sinh viên từ hệ thống chính</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 700,
|
||||
boxShadow: '0 4px 14px rgba(187,33,38,0.25)',
|
||||
}}
|
||||
>
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="sync-spinner"></div> Đồng bộ...
|
||||
<div className="sync-spinner" style={{ width: 18, height: 18 }} />
|
||||
Đồng bộ... {syncPercent}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
|
||||
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
<IconRefresh />
|
||||
Đồng bộ Lớp học
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
|
||||
<div className="sync-progress-banner">
|
||||
<div className="sync-header">
|
||||
<div className="sync-title">
|
||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
||||
{syncStatus.running && <div className="sync-spinner" />}
|
||||
<span>
|
||||
{syncStatus.running && `Đang đồng bộ lớp học... (${syncPercent}%)`}
|
||||
{syncStatus.running && `Đang đồng bộ... ${syncPercent}%`}
|
||||
{syncStatus.done && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Đồng bộ lớp học hoàn tất!
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--success)' }}>
|
||||
<IconCheck />
|
||||
Đồng bộ hoàn tất!
|
||||
</span>
|
||||
)}
|
||||
{syncStatus.error && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
Đồng bộ thất bại: {syncStatus.error}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--danger)' }}>
|
||||
<IconAlert />
|
||||
{syncStatus.error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -170,7 +252,7 @@ export const ClassesTab: React.FC = () => {
|
||||
</div>
|
||||
{syncStatus.running && (
|
||||
<div className="sync-bar-container">
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%` }}></div>
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-meta">
|
||||
@@ -180,7 +262,6 @@ export const ClassesTab: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control bar filters */}
|
||||
<div className="control-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
@@ -194,10 +275,7 @@ export const ClassesTab: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
<span className="search-icon">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<IconSearch />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -212,10 +290,10 @@ export const ClassesTab: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<option value="">Tất cả phân hệ</option>
|
||||
<option value="1">Hà Nội (System 1)</option>
|
||||
<option value="2">Hồ Chí Minh (System 2)</option>
|
||||
<option value="3">Đà Nẵng (System 3)</option>
|
||||
<option value="4">Cần Thơ (System 4)</option>
|
||||
<option value="1">Hà Nội</option>
|
||||
<option value="2">Hồ Chí Minh</option>
|
||||
<option value="3">Đà Nẵng</option>
|
||||
<option value="4">Cần Thơ</option>
|
||||
</select>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.9rem' }}>
|
||||
@@ -226,7 +304,7 @@ export const ClassesTab: React.FC = () => {
|
||||
setStudyingOnly(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: '16px', height: '16px', accentColor: 'var(--accent)' }}
|
||||
style={{ width: '18px', height: '18px', accentColor: 'var(--accent)', cursor: 'pointer' }}
|
||||
/>
|
||||
Chỉ xem lớp đang học
|
||||
</label>
|
||||
@@ -234,172 +312,613 @@ export const ClassesTab: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classes list table */}
|
||||
<div className="tab-page-body">
|
||||
<div className="table-wrapper table-fill">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách lớp học...</p>
|
||||
<div className="sync-spinner" style={{ width: '40px', height: '40px' }} />
|
||||
<p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách lớp học...</p>
|
||||
</div>
|
||||
) : classes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
||||
<path d="M6 12v5c0 2 2 3 6 3s6-1 6-3v-5" />
|
||||
</svg>
|
||||
<IconInbox />
|
||||
</div>
|
||||
<h2>Không tìm thấy lớp học nào</h2>
|
||||
<p>Hãy thử thay đổi bộ lọc hoặc bấm nút "Đồng bộ Lớp học" để kéo dữ liệu mới.</p>
|
||||
<h2>Không tìm thấy lớp học</h2>
|
||||
<p>Hãy thay đổi bộ lọc hoặc bấm "Đồng bộ Lớp học" để kéo dữ liệu mới.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<div className="table-scroll-container">
|
||||
<table className="data-table class-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã lớp / ID</th>
|
||||
<th>Tên Lớp học</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Môn học của lớp</th>
|
||||
<th>Sinh viên</th>
|
||||
<th>Đang dạy</th>
|
||||
<th style={{ textAlign: 'right' }}>Hành động</th>
|
||||
<th className="col-code">Mã lớp / ID</th>
|
||||
<th className="col-name">Tên lớp học</th>
|
||||
<th className="col-system">Phân hệ</th>
|
||||
<th className="col-courses">Môn học</th>
|
||||
<th className="col-students">Sinh viên</th>
|
||||
<th className="col-status">Trạng thái</th>
|
||||
<th className="col-action">Hành động</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(cl => (
|
||||
<tr key={cl.rkId} style={cl.isStudying ? { background: 'rgba(16, 185, 129, 0.02)' } : {}}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 700, color: 'var(--text-primary)' }}>{cl.classCode}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {cl.rkId}</div>
|
||||
{classes.map(cl => {
|
||||
const isActive = cl.isStudying;
|
||||
return (
|
||||
<tr key={cl.rkId} className={isActive ? 'row-active' : ''}>
|
||||
<td className="col-code" data-label="Mã lớp / ID">
|
||||
<div className="class-code" title={cl.classCode}>{cl.classCode}</div>
|
||||
<div className="class-id">ID: {cl.rkId}</div>
|
||||
</td>
|
||||
<td>
|
||||
<td className="col-name" data-label="Tên lớp học">
|
||||
<div
|
||||
style={{ fontWeight: 600, color: 'var(--accent)', fontSize: '0.95rem', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
className="class-name-link"
|
||||
onClick={() => openClass('classes', cl.rkId, cl.name)}
|
||||
title="Mở Không gian làm việc của lớp"
|
||||
title={cl.name}
|
||||
>
|
||||
{cl.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.15rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
<div className="class-specialize">
|
||||
<IconSpecialize />
|
||||
<span className="class-specialize-text">
|
||||
{cl.specializeName || 'Chưa có chuyên ngành'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted" style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
<td className="col-system" data-label="Phân hệ">
|
||||
<span className="badge badge-system">
|
||||
{cl.systemName || `Hệ thống ${cl.systemRkId || 'Khác'}`}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="courses-tag-list">
|
||||
<td className="col-courses" data-label="Môn học">
|
||||
<div className="course-tags">
|
||||
{cl.courses && cl.courses.length > 0 ? (
|
||||
cl.courses.map((co, i) => (
|
||||
<span key={i} className="course-tag">
|
||||
<span key={i} className="course-tag" title={co.courseName}>
|
||||
{co.courseName}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Chưa có môn học</span>
|
||||
<span className="text-muted-small">Chưa có môn học</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-info" style={{ padding: '0.3rem 0.65rem', borderRadius: '6px', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
{cl.studentCount} SV
|
||||
<td className="col-students" data-label="Sinh viên">
|
||||
<span className="badge badge-student">
|
||||
<IconUsers />
|
||||
<span className="student-count">{cl.studentCount}</span>
|
||||
<span>SV</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<td className="col-status" data-label="Trạng thái">
|
||||
<div className="status-toggle-wrap">
|
||||
<div className="switch-container">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cl.isStudying}
|
||||
checked={isActive}
|
||||
onChange={() => handleToggleStudying(cl)}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
{cl.isStudying ? (
|
||||
<span style={{ color: 'var(--success)', fontSize: '0.72rem', display: 'inline-flex', alignItems: 'center', gap: '0.25rem', fontWeight: 700, textTransform: 'uppercase' }}>
|
||||
<span className="pulse-dot-active" style={{ display: 'inline-block', width: '6px', height: '6px', background: '#10b981', borderRadius: '50%', boxShadow: '0 0 6px #10b981' }}></span>
|
||||
Đang học
|
||||
<span className={`status-label ${isActive ? 'active' : 'inactive'}`}>
|
||||
{isActive ? 'Đang học' : 'Tạm dừng'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.72rem', textTransform: 'uppercase', fontWeight: 600 }}>Tạm dừng</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<td className="col-action" data-label="Hành động">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '0.5rem 0.95rem', fontSize: '0.8rem', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
|
||||
className="btn btn-outline"
|
||||
onClick={() => openClass('classes', cl.rkId, cl.name)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
Xem lớp
|
||||
<span className="btn-outline-label">Xem lớp</span>
|
||||
<IconOpen />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination control bar */}
|
||||
{!loading && classes.length > 0 && (
|
||||
<div className="tab-page-footer">
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị lớp thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp học
|
||||
<div className="pagination-info">
|
||||
Hiển thị <b>{(page - 1) * pageSize + 1}</b> –{' '}
|
||||
<b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> lớp
|
||||
</div>
|
||||
<div className="pagination-btn-group">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
aria-label="Trang trước"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
<IconChevronLeft />
|
||||
</button>
|
||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||
<span className="pagination-current">
|
||||
Trang {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Trang sau"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<IconChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.table-wrapper.table-fill {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.table-scroll-container {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.class-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.class-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-subtle);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.class-table td {
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.class-table .col-code { width: 16%; }
|
||||
.class-table .col-name { width: 22%; }
|
||||
.class-table .col-system { width: 14%; }
|
||||
.class-table .col-courses { width: 16%; }
|
||||
.class-table .col-students { width: 9%; }
|
||||
.class-table .col-status { width: 12%; }
|
||||
.class-table .col-action { width: 11%; text-align: right; }
|
||||
|
||||
.class-table td.col-students,
|
||||
.class-table td.col-status,
|
||||
.class-table td.col-action {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.class-table tbody tr {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.class-table tbody tr:hover {
|
||||
background: var(--bg-subtle) !important;
|
||||
}
|
||||
.class-table tbody tr.row-active {
|
||||
background: rgba(13, 159, 110, 0.02);
|
||||
}
|
||||
.class-table tbody tr.row-active:hover {
|
||||
background: rgba(13, 159, 110, 0.05) !important;
|
||||
}
|
||||
|
||||
.class-code {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.class-id {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.class-name-link {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.15s;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.class-name-link:hover {
|
||||
color: var(--accent-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.page-title-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.page-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.class-specialize {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.class-specialize svg {
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.class-specialize-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge-system {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.badge-student {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(187,33,38,0.15);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
min-width: 4.75rem;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.badge-student svg {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
.badge-student .student-count {
|
||||
display: inline-block;
|
||||
min-width: 2ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.course-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
.course-tag {
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.text-muted-small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-label.active {
|
||||
color: var(--success);
|
||||
}
|
||||
.status-label.inactive {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
transition: all 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.btn-outline:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.pagination-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
padding: 0 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagination-btn-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Medium: drop secondary columns, no horizontal scroll */
|
||||
@media (max-width: 1280px) {
|
||||
.class-table .col-courses {
|
||||
display: none;
|
||||
}
|
||||
.class-table .col-code { width: 18%; }
|
||||
.class-table .col-name { width: 28%; }
|
||||
.class-table .col-system { width: 16%; }
|
||||
.class-table .col-students { width: 10%; }
|
||||
.class-table .col-status { width: 14%; }
|
||||
.class-table .col-action { width: 14%; }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.class-table .col-system {
|
||||
display: none;
|
||||
}
|
||||
.class-table .col-code { width: 20%; }
|
||||
.class-table .col-name { width: 36%; }
|
||||
.class-table .col-students { width: 12%; }
|
||||
.class-table .col-status { width: 16%; }
|
||||
.class-table .col-action { width: 16%; }
|
||||
.status-label {
|
||||
display: none;
|
||||
}
|
||||
.btn-outline {
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.btn-outline-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card layout before horizontal overflow kicks in */
|
||||
@media (max-width: 960px) {
|
||||
.table-scroll-container {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.class-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
.class-table thead {
|
||||
display: none;
|
||||
}
|
||||
.class-table tbody tr {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.class-table tbody tr.row-active {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
.class-table .col-courses,
|
||||
.class-table .col-system {
|
||||
display: flex;
|
||||
}
|
||||
.class-table td {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
gap: 0.1rem;
|
||||
overflow: visible;
|
||||
width: auto !important;
|
||||
}
|
||||
.class-table td::before {
|
||||
content: attr(data-label);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
.class-table td.col-action {
|
||||
grid-column: span 2;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem !important;
|
||||
border-top: 1px dashed var(--border-color) !important;
|
||||
}
|
||||
.class-table td.col-action::before {
|
||||
margin-bottom: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.class-code,
|
||||
.class-name-link,
|
||||
.class-specialize-text,
|
||||
.badge-system {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
.status-label {
|
||||
display: inline;
|
||||
}
|
||||
.btn-outline-label {
|
||||
display: inline;
|
||||
}
|
||||
.class-code {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.class-name-link {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.class-id {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.class-specialize {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.badge-system,
|
||||
.badge-student {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
.course-tag {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.btn-outline {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
}
|
||||
.pagination-row {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.pagination-info {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.page-header .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.control-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search-input-wrapper {
|
||||
max-width: 100%;
|
||||
}
|
||||
.filters-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.select-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.class-table tbody tr {
|
||||
padding: 0.75rem;
|
||||
gap: 0.4rem 0.75rem;
|
||||
}
|
||||
.class-code {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.class-name-link {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.badge-system,
|
||||
.badge-student {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
.btn-outline {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
.pagination-btn-group .pagination-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,86 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { getWsUrl, type ExamRoomStudent } from '../api';
|
||||
import { type ExamRoomStudent } from '../api';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { StudentStreamImage } from './StudentStreamImage';
|
||||
|
||||
/* ─── Icons ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconSearch = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMaximize = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMinimize = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconOffline = ({ size = 28 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
<line x1="2" y1="3" x2="22" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMessage = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconEye = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconFile = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronLeft = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronRight = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ─── Component ──────────────────────────────────────────────────────────── */
|
||||
|
||||
interface ExamGridProctorProps {
|
||||
students: ExamRoomStudent[];
|
||||
@@ -13,165 +93,24 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
onlineIds,
|
||||
onSelectStudent,
|
||||
}) => {
|
||||
const [screenFrames, setScreenFrames] = useState<Record<number, string>>({});
|
||||
const [webcamFrames, setWebcamFrames] = useState<Record<number, string>>({});
|
||||
const [gridCols, setGridCols] = useState<number>(3);
|
||||
const [showWebcamOverlay, setShowWebcamOverlay] = useState<boolean>(true);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [onlyOnline, setOnlyOnline] = useState<boolean>(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||
const [zoomedStudent, setZoomedStudent] = useState<ExamRoomStudent | null>(null);
|
||||
const [pageSize, setPageSize] = useState<number | 'all'>(12);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const subscribedRef = useRef<Set<number>>(new Set());
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const studentIdsString = useMemo(
|
||||
() => students.map((s) => s.studentRkId).join(','),
|
||||
[students]
|
||||
);
|
||||
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
|
||||
|
||||
const syncSubscriptions = (ws: WebSocket) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
const target = new Set(onlineIds);
|
||||
const prev = subscribedRef.current;
|
||||
|
||||
prev.forEach((id) => {
|
||||
if (!target.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
|
||||
prev.delete(id);
|
||||
}
|
||||
});
|
||||
onlineIds.forEach((id) => {
|
||||
if (!prev.has(id)) {
|
||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
|
||||
prev.add(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (students.length === 0) return;
|
||||
|
||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||
let closed = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
const clearPing = () => {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setZoomedStudent(null);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
subscribedRef.current = new Set();
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
syncSubscriptions(ws);
|
||||
clearPing();
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
|
||||
}
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.event === 'client:pong') return;
|
||||
if (msg.event === 'teacher:screenshot-stream-frame') {
|
||||
const { studentId, imageBuffer } = msg.data;
|
||||
setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
||||
} else if (msg.event === 'teacher:webcam-stream-frame') {
|
||||
const { studentId, imageBuffer } = msg.data;
|
||||
setWebcamFrames((prev) => ({ ...prev, [studentId]: imageBuffer }));
|
||||
} else if (msg.event === 'teacher:stream-stopped') {
|
||||
const { studentId } = msg.data;
|
||||
setScreenFrames((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[studentId];
|
||||
return next;
|
||||
});
|
||||
setWebcamFrames((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[studentId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing WS message in grid view:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearPing();
|
||||
subscribedRef.current = new Set();
|
||||
if (!closed) {
|
||||
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
clearPing();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
subscribedRef.current.forEach((id) => {
|
||||
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
|
||||
});
|
||||
}
|
||||
ws?.close();
|
||||
wsRef.current = null;
|
||||
subscribedRef.current = new Set();
|
||||
};
|
||||
}, [studentIdsString]);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = wsRef.current;
|
||||
if (ws) syncSubscriptions(ws);
|
||||
}, [onlineKey]);
|
||||
|
||||
// Clean up frames when students are removed or go offline
|
||||
useEffect(() => {
|
||||
setScreenFrames((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
Object.keys(next).forEach((idStr) => {
|
||||
const id = Number(idStr);
|
||||
if (!onlineIds.includes(id)) {
|
||||
delete next[id];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setWebcamFrames((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
Object.keys(next).forEach((idStr) => {
|
||||
const id = Number(idStr);
|
||||
if (!onlineIds.includes(id)) {
|
||||
delete next[id];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [onlineIds]);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const filteredStudents = useMemo(() => {
|
||||
return students.filter((s) => {
|
||||
@@ -179,11 +118,25 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const matchesOnlineFilter = !onlyOnline || isOnline;
|
||||
return matchesSearch && matchesOnlineFilter;
|
||||
return matchesSearch && (!onlyOnline || isOnline);
|
||||
});
|
||||
}, [students, onlineIds, searchQuery, onlyOnline]);
|
||||
|
||||
const pagedStudents = useMemo(() => {
|
||||
if (pageSize === 'all') return filteredStudents;
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return filteredStudents.slice(start, start + pageSize);
|
||||
}, [filteredStudents, currentPage, pageSize]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (pageSize === 'all') return 1;
|
||||
return Math.ceil(filteredStudents.length / pageSize) || 1;
|
||||
}, [filteredStudents, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [totalPages, currentPage]);
|
||||
|
||||
const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openStaffChat({
|
||||
@@ -213,95 +166,77 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-proctor-container" ref={gridContainerRef}>
|
||||
<div className={`grid-proctor-container ${isFullscreen ? 'fullscreen-active' : ''}`} ref={gridContainerRef}>
|
||||
<div className="grid-proctor-toolbar">
|
||||
<div className="grid-proctor-toolbar-left">
|
||||
<div className="search-input-wrapper">
|
||||
<div className="gp-search">
|
||||
<span className="gp-search-icon"><IconSearch size={14} /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
type="search"
|
||||
className="search-input gp-search-input"
|
||||
placeholder="Lọc sinh viên..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyOnline}
|
||||
onChange={(e) => setOnlyOnline(e.target.checked)}
|
||||
/>
|
||||
<label className="gp-check">
|
||||
<input type="checkbox" checked={onlyOnline} onChange={(e) => setOnlyOnline(e.target.checked)} />
|
||||
<span>Chỉ hiện Online</span>
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWebcamOverlay}
|
||||
onChange={(e) => setShowWebcamOverlay(e.target.checked)}
|
||||
/>
|
||||
<label className="gp-check">
|
||||
<input type="checkbox" checked={showWebcamOverlay} onChange={(e) => setShowWebcamOverlay(e.target.checked)} />
|
||||
<span>Đè webcam góc màn hình</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-toolbar-right">
|
||||
<div className="grid-cols-selector">
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Cột:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 2 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(2)}
|
||||
>
|
||||
2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 3 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(3)}
|
||||
>
|
||||
3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 4 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(4)}
|
||||
>
|
||||
4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary btn-xs ${gridCols === 6 ? 'active' : ''}`}
|
||||
onClick={() => setGridCols(6)}
|
||||
>
|
||||
6
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? '🖥️ Thu nhỏ' : '🖥️ Toàn màn hình'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid-proctor-layout`}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||
gap: '1rem',
|
||||
padding: '1rem 0',
|
||||
<label className="gp-page-size">
|
||||
<span>Xem tối đa</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setPageSize(val === 'all' ? 'all' : Number(val));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
{filteredStudents.map((s) => {
|
||||
<option value={12}>12 bạn</option>
|
||||
<option value={24}>24 bạn</option>
|
||||
<option value={48}>48 bạn</option>
|
||||
<option value="all">Tất cả</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid-cols-selector" role="group" aria-label="Số cột">
|
||||
<span className="gp-cols-label">Cột</span>
|
||||
{[2, 3, 4, 6].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={gridCols === n ? 'active' : ''}
|
||||
onClick={() => setGridCols(n)}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn btn-secondary btn-sm gp-fs-btn" onClick={toggleFullscreen}>
|
||||
{isFullscreen ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
|
||||
{isFullscreen ? 'Thu nhỏ' : 'Toàn màn hình'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-scroll">
|
||||
<div
|
||||
className="grid-proctor-layout"
|
||||
style={{ gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{pagedStudents.map((s) => {
|
||||
const isOnline = onlineIds.includes(s.studentRkId);
|
||||
const screenFrame = screenFrames[s.studentRkId];
|
||||
const webcamFrame = webcamFrames[s.studentRkId];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -312,69 +247,67 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
<div className="grid-proctor-card-header">
|
||||
<div className="student-info-left">
|
||||
<span className={`status-dot ${isOnline ? 'online' : 'offline'}`} />
|
||||
<span className="student-name" title={s.fullName}>
|
||||
{s.fullName}
|
||||
</span>
|
||||
<span className="student-name" title={s.fullName}>{s.fullName}</span>
|
||||
</div>
|
||||
<span className="student-code">{s.studentCode}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-card-body">
|
||||
{isOnline ? (
|
||||
<div className="proctor-frame-container">
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
alt={`Màn hình ${s.fullName}`}
|
||||
<div
|
||||
className="proctor-frame-container cursor-zoom-in"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setZoomedStudent(s);
|
||||
}}
|
||||
>
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="screen"
|
||||
className="proctor-screen-image"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="proctor-placeholder streaming">
|
||||
<div className="sync-spinner" style={{ width: '20px', height: '20px', borderWidth: '2px', marginBottom: '0.5rem' }} />
|
||||
<span>Đang kết nối màn hình...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWebcamOverlay && webcamFrame && (
|
||||
{showWebcamOverlay && (
|
||||
<div className="proctor-webcam-overlay">
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt={`Webcam ${s.fullName}`}
|
||||
<StudentStreamImage
|
||||
studentId={s.studentRkId}
|
||||
kind="webcam"
|
||||
className="proctor-webcam-image"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="proctor-placeholder offline">
|
||||
<span className="icon">📴</span>
|
||||
<span>Ngoại tuyến (Offline)</span>
|
||||
<span className="gp-offline-icon"><IconOffline size={26} /></span>
|
||||
<span className="gp-offline-label">Ngoại tuyến</span>
|
||||
<span className="gp-offline-sub">Offline</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid-proctor-card-footer" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="assigned-paper" title={s.paperTitle || 'Chưa gán đề'}>
|
||||
{s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
|
||||
<IconFile size={12} />
|
||||
{s.paperTitle ? s.paperTitle : 'Chưa gán đề'}
|
||||
</span>
|
||||
<div className="footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-primary"
|
||||
className="gp-action-btn"
|
||||
onClick={(e) => handleOpenChat(s, e)}
|
||||
title="Nhắn tin cho sinh viên"
|
||||
>
|
||||
💬 Nhắn tin
|
||||
<IconMessage size={13} />
|
||||
Nhắn tin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
className="gp-action-btn gp-action-btn--primary"
|
||||
onClick={() => onSelectStudent(s)}
|
||||
title="Xem chi tiết giám sát"
|
||||
>
|
||||
🔍 Giám sát
|
||||
<IconEye size={13} />
|
||||
Giám sát
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -383,12 +316,77 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||
})}
|
||||
|
||||
{filteredStudents.length === 0 && (
|
||||
<div className="grid-proctor-empty" style={{ gridColumn: '1 / -1' }}>
|
||||
<span>🔍</span>
|
||||
<div className="grid-proctor-empty">
|
||||
<span className="gp-empty-icon"><IconSearch size={28} /></span>
|
||||
<p>Không tìm thấy sinh viên nào.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="grid-proctor-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm gp-page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
>
|
||||
<IconChevronLeft size={14} />
|
||||
Trang trước
|
||||
</button>
|
||||
<span className="gp-page-info">
|
||||
Trang {currentPage} / {totalPages}
|
||||
<span className="gp-page-total"> · {filteredStudents.length} bạn</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm gp-page-btn"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
>
|
||||
Trang sau
|
||||
<IconChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{zoomedStudent && (
|
||||
<div className="zoomed-proctor-overlay" onClick={() => setZoomedStudent(null)}>
|
||||
<div className="zoomed-proctor-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="zoomed-proctor-header">
|
||||
<div className="zoomed-proctor-title">
|
||||
<span
|
||||
className={`status-dot ${onlineIds.includes(zoomedStudent.studentRkId) ? 'online' : 'offline'}`}
|
||||
/>
|
||||
<h3>{zoomedStudent.fullName}</h3>
|
||||
<span className="student-code">{zoomedStudent.studentCode}</span>
|
||||
</div>
|
||||
<button type="button" className="zoomed-proctor-close" onClick={() => setZoomedStudent(null)} aria-label="Đóng">
|
||||
<IconClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="zoomed-proctor-body">
|
||||
<div className="zoomed-proctor-frame">
|
||||
<StudentStreamImage
|
||||
studentId={zoomedStudent.studentRkId}
|
||||
kind="screen"
|
||||
className="zoomed-proctor-screen"
|
||||
/>
|
||||
{showWebcamOverlay && (
|
||||
<div className="zoomed-proctor-webcam">
|
||||
<StudentStreamImage
|
||||
studentId={zoomedStudent.studentRkId}
|
||||
kind="webcam"
|
||||
className="proctor-webcam-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
apiExam,
|
||||
apiSeatingLayout,
|
||||
staffFetch,
|
||||
type ExamPaper,
|
||||
type ExamRoomStudent,
|
||||
@@ -25,6 +26,101 @@ import { ViolationsPanel } from './ViolationsPanel';
|
||||
|
||||
const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])];
|
||||
|
||||
/* ─── Icons (match ClassWorkspace) ───────────────────────────────────────── */
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconExam = ({ size = 20 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="9" y1="13" x2="15" y2="13" />
|
||||
<line x1="9" y1="17" x2="13" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSettings = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClose = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMap = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
|
||||
<line x1="9" y1="3" x2="9" y2="18" />
|
||||
<line x1="15" y1="6" x2="15" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMonitor = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconList = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" />
|
||||
<line x1="3" y1="12" x2="3.01" y2="12" />
|
||||
<line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPackage = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconShield = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconUsers = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 36 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface Props {
|
||||
examId: number;
|
||||
onBack: () => void;
|
||||
@@ -69,6 +165,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [canPublish, setCanPublish] = useState(false);
|
||||
const [canUnpublish, setCanUnpublish] = useState(false);
|
||||
const [canCancel, setCanCancel] = useState(false);
|
||||
const [canExtend, setCanExtend] = useState(false);
|
||||
const [extending, setExtending] = useState(false);
|
||||
const [papers, setPapers] = useState<ExamPaper[]>([]);
|
||||
const [students, setStudents] = useState<ExamRoomStudent[]>([]);
|
||||
const [submissions, setSubmissions] = useState<ExamSubmission[]>([]);
|
||||
@@ -76,8 +174,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [searchHits, setSearchHits] = useState<{ studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[]>([]);
|
||||
const [selectedPickerRkIds, setSelectedPickerRkIds] = useState<number[]>([]);
|
||||
const [paperTitle, setPaperTitle] = useState('');
|
||||
const [packagePdf, setPackagePdf] = useState<File | null>(null);
|
||||
const [packageResources, setPackageResources] = useState<File[]>([]);
|
||||
const [packagePdfs, setPackagePdfs] = useState<File[]>([]);
|
||||
const [creatingPackage, setCreatingPackage] = useState(false);
|
||||
const [sendSchedule, setSendSchedule] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
@@ -101,6 +198,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
|
||||
const anyPaperSent = students.some((s) => s.paperSentAt);
|
||||
const canModifyPapers = prepEditable || (!anyPaperSent && (displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active'));
|
||||
const canRemoveStudents = displayStatus !== 'ended' && displayStatus !== 'cancelled';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -118,6 +216,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
setCanPublish(data.canPublish);
|
||||
setCanUnpublish(data.canUnpublish);
|
||||
setCanCancel(data.canCancel);
|
||||
setCanExtend(!!data.canExtend || data.displayStatus === 'active');
|
||||
setPapers(data.papers);
|
||||
setStudents(data.students);
|
||||
pushNav({
|
||||
@@ -231,6 +330,20 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveExtendedEndTime = async () => {
|
||||
setErr(''); setMsg('');
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiExam.update(examId, { endTime: localInputToISO(end) });
|
||||
setMsg('Đã gia hạn giờ kết thúc phòng thi');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addStudent = async (studentRkId: number) => {
|
||||
await apiExam.addStudents(examId, [studentRkId]);
|
||||
await load();
|
||||
@@ -293,21 +406,35 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createPackage = async () => {
|
||||
if (!packagePdf) {
|
||||
setErr('Chọn file PDF cho gói đề');
|
||||
const createPackagesBulk = async () => {
|
||||
if (!packagePdfs.length) {
|
||||
setErr('Chọn ít nhất một file PDF đề chính');
|
||||
return;
|
||||
}
|
||||
setErr('');
|
||||
setCreatingPackage(true);
|
||||
try {
|
||||
const title = paperTitle.trim() || packagePdf.name.replace(/\.pdf$/i, '') || 'Gói đề';
|
||||
const resCount = packageResources.length;
|
||||
await apiExam.uploadPackage(examId, title, packagePdf, packageResources);
|
||||
const prefix = paperTitle.trim();
|
||||
const createdTitle = packagePdfs.length === 1
|
||||
? (prefix || packagePdfs[0].name.replace(/\.pdf$/i, '') || 'Gói đề')
|
||||
: '';
|
||||
let created = 0;
|
||||
for (let i = 0; i < packagePdfs.length; i++) {
|
||||
const pdf = packagePdfs[i];
|
||||
const baseName = pdf.name.replace(/\.pdf$/i, '') || 'Gói đề';
|
||||
const title = packagePdfs.length === 1
|
||||
? (prefix || baseName)
|
||||
: baseName;
|
||||
await apiExam.uploadPackage(examId, title, pdf, []);
|
||||
created++;
|
||||
}
|
||||
setPaperTitle('');
|
||||
setPackagePdf(null);
|
||||
setPackageResources([]);
|
||||
setMsg(`Đã tạo gói đề "${title}" (1 PDF${resCount ? ` + ${resCount} tài nguyên` : ''})`);
|
||||
setPackagePdfs([]);
|
||||
setMsg(
|
||||
created === 1
|
||||
? `Đã tạo gói đề "${createdTitle}". Bổ sung tài nguyên bên dưới nếu cần.`
|
||||
: `Đã tạo ${created} gói đề. Bổ sung tài nguyên cho từng gói bên dưới nếu cần.`,
|
||||
);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Tạo gói đề thất bại');
|
||||
@@ -316,6 +443,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const removePackagePdf = (index: number) => {
|
||||
setPackagePdfs((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const uploadResource = async (paperId: number, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
@@ -325,9 +456,63 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
};
|
||||
|
||||
const assignRandomPackages = async () => {
|
||||
if (papers.length === 0) return;
|
||||
|
||||
// Đọc sơ đồ chỗ ngồi từ server
|
||||
let seatingSeats: Record<string, number> | null = null;
|
||||
try {
|
||||
const json = await apiSeatingLayout.getExam(examId);
|
||||
if (json) {
|
||||
const parsed = JSON.parse(json) as { seats?: Record<string, number> };
|
||||
if (parsed.seats && Object.keys(parsed.seats).length > 0) {
|
||||
seatingSeats = parsed.seats;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!seatingSeats) {
|
||||
// Không có sơ đồ → fallback random server-side
|
||||
const res = await apiExam.assignRandom(examId);
|
||||
setMsg(`Đã chia ${res.assigned} sinh viên vào ${res.packages} gói đề`);
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
|
||||
// Nhóm sinh viên theo hàng: row → [(col, studentRkId)] tăng dần theo cột
|
||||
const rowMap = new Map<number, { col: number; rkId: number }[]>();
|
||||
Object.entries(seatingSeats).forEach(([key, rkId]) => {
|
||||
const [r, c] = key.split(',').map(Number);
|
||||
if (!rowMap.has(r)) rowMap.set(r, []);
|
||||
rowMap.get(r)!.push({ col: c, rkId });
|
||||
});
|
||||
rowMap.forEach((arr) => arr.sort((a, b) => a.col - b.col));
|
||||
|
||||
const numPapers = papers.length;
|
||||
const assignments: { studentRkId: number; paperId: number }[] = [];
|
||||
const assignedIds = new Set<number>();
|
||||
|
||||
// Chia đề theo hàng ngang: mỗi hàng có random offset → cạnh nhau không trùng
|
||||
[...rowMap.keys()].sort((a, b) => a - b).forEach((row) => {
|
||||
const seatsInRow = rowMap.get(row)!;
|
||||
const offset = Math.floor(Math.random() * numPapers);
|
||||
seatsInRow.forEach(({ rkId }, relIdx) => {
|
||||
const paper = papers[(offset + relIdx) % numPapers];
|
||||
assignments.push({ studentRkId: rkId, paperId: paper.id });
|
||||
assignedIds.add(rkId);
|
||||
});
|
||||
});
|
||||
|
||||
// Sinh viên chưa có chỗ ngồi → chia tuần tự ngẫu nhiên
|
||||
const unseated = students
|
||||
.filter((s) => !assignedIds.has(s.studentRkId))
|
||||
.sort(() => Math.random() - 0.5);
|
||||
unseated.forEach((s, i) => {
|
||||
assignments.push({ studentRkId: s.studentRkId, paperId: papers[i % numPapers].id });
|
||||
});
|
||||
|
||||
const res = await apiExam.assignPapersBatch(examId, assignments);
|
||||
setMsg(`Đã chia ${res.assigned} sinh viên vào ${numPapers} gói đề (theo sơ đồ chỗ ngồi)`);
|
||||
await load();
|
||||
};
|
||||
|
||||
const sendPapers = async (scheduled: boolean) => {
|
||||
@@ -503,6 +688,21 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtend = async (minutes: number) => {
|
||||
if (!confirm(`Gia hạn thêm ${minutes} phút cho phòng thi đang diễn ra?`)) return;
|
||||
setErr(''); setMsg('');
|
||||
setExtending(true);
|
||||
try {
|
||||
const res = await apiExam.extend(examId, minutes);
|
||||
setMsg(`Đã gia hạn +${minutes} phút — kết thúc lúc ${fmtTime(res.endTime)}`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Gia hạn thất bại');
|
||||
} finally {
|
||||
setExtending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredStudents = students.filter((s) => {
|
||||
const term = searchQuery.toLowerCase().trim();
|
||||
if (!term) return true;
|
||||
@@ -555,11 +755,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className={`alert-banner ${err ? 'alert-error' : 'alert-success'}`}>{err || msg}</div>
|
||||
)}
|
||||
|
||||
<div className="workspace-header">
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div className="brand-logo" style={{ width: 44, height: 44, fontSize: '1.1rem' }}>📝</div>
|
||||
<div>
|
||||
<div className="workspace-header ew-header">
|
||||
<div className="ew-header-left">
|
||||
<div className="ew-brand-icon">
|
||||
<IconExam size={20} />
|
||||
</div>
|
||||
<div className="ew-title-block">
|
||||
<h1 className="workspace-class-title">{roomName || 'Phòng thi'}</h1>
|
||||
<div className="class-badge-container">
|
||||
<span className={`badge ${badge.cls}`}>{badge.text}</span>
|
||||
@@ -573,19 +774,16 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1.25rem', flexWrap: 'wrap' }}>
|
||||
<div className="status-panel">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Trạng thái thi
|
||||
</span>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: statusColor(displayStatus) }}>
|
||||
<div className="ew-header-right">
|
||||
<div className="status-panel ew-status-panel">
|
||||
<div className="ew-status-label-group">
|
||||
<span className="ew-meta-label">Trạng thái thi</span>
|
||||
<span className="ew-status-value" style={{ color: statusColor(displayStatus) }}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<div className="ew-status-actions">
|
||||
{canPublish && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={handlePublish}>Đẩy phòng thi</button>
|
||||
)}
|
||||
@@ -595,21 +793,38 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
{canCancel && (
|
||||
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
|
||||
)}
|
||||
{canExtend && (
|
||||
<div className="ew-extend-row">
|
||||
<span className="ew-meta-label">Gia hạn</span>
|
||||
{[15, 30, 60].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={extending}
|
||||
onClick={() => handleExtend(m)}
|
||||
title={`Thêm ${m} phút vào giờ kết thúc`}
|
||||
>
|
||||
+{m}p
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{papers.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||
📄 Xem đề
|
||||
<button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
|
||||
<IconExam size={13} />
|
||||
Xem đề
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Online / Sĩ số
|
||||
</div>
|
||||
<div className="workspace-stat-value">
|
||||
<div className="ew-online-stat">
|
||||
<span className="ew-meta-label">Online / Sĩ số</span>
|
||||
<div className="workspace-stat-value ew-online-value">
|
||||
<IconUsers size={13} />
|
||||
<span style={{ color: 'var(--success)' }}>{onlineCount}</span>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem', fontWeight: 500 }}> / {students.length} SV</span>
|
||||
<span className="ew-online-total">/ {students.length} SV</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,8 +837,13 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
|
||||
<aside className={`workspace-config-drawer ${configOpen ? 'open' : ''}`}>
|
||||
<div className="workspace-drawer-header">
|
||||
<strong>Cấu hình phòng thi</strong>
|
||||
<button type="button" className="btn btn-secondary workspace-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">×</button>
|
||||
<span className="ew-drawer-title">
|
||||
<IconSettings size={16} />
|
||||
Cấu hình phòng thi
|
||||
</span>
|
||||
<button type="button" className="ew-drawer-close" onClick={() => setConfigOpen(false)} aria-label="Đóng">
|
||||
<IconClose size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="workspace-drawer-tabs">
|
||||
<button type="button" className={`workspace-drawer-tab ${configTab === 'info' ? 'active' : ''}`} onClick={() => setConfigTab('info')}>
|
||||
@@ -641,7 +861,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Thông tin phòng thi</div>
|
||||
<p className="config-card-desc">
|
||||
{editable ? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.' : 'Phòng đã khóa chỉnh sửa.'}
|
||||
{editable
|
||||
? 'Chỉnh sửa được khi phòng ở trạng thái tạm thời.'
|
||||
: canExtend
|
||||
? 'Phòng đang thi — có thể gia hạn giờ kết thúc bên dưới hoặc dùng nút +15p / +30p / +60p trên thanh trạng thái.'
|
||||
: 'Phòng đã khóa chỉnh sửa.'}
|
||||
</p>
|
||||
<label className="login-field">
|
||||
<span>Tên phòng thi</span>
|
||||
@@ -682,13 +906,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Kết thúc</span>
|
||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} disabled={!editable} />
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
disabled={!editable && !canExtend}
|
||||
/>
|
||||
</label>
|
||||
{editable && (
|
||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||
{saving ? 'Đang lưu...' : 'Lưu thông tin'}
|
||||
{(editable || canExtend) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
disabled={saving}
|
||||
onClick={editable ? saveRoom : saveExtendedEndTime}
|
||||
>
|
||||
{saving ? 'Đang lưu...' : canExtend && !editable ? 'Lưu giờ kết thúc (gia hạn)' : 'Lưu thông tin'}
|
||||
</button>
|
||||
)}
|
||||
{canExtend && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
||||
{[15, 30, 60].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={extending}
|
||||
onClick={() => handleExtend(m)}
|
||||
>
|
||||
+{m} phút
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -750,7 +1000,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="config-card config-card-flat">
|
||||
<div className="card-header-title">Gói đề & gửi cho sinh viên</div>
|
||||
<p className="config-card-desc">
|
||||
Mỗi gói đề gồm <strong>1 file PDF</strong> (đề chính) và <strong>tài nguyên kèm</strong> (ảnh, zip, doc…). Chia ngẫu nhiên sẽ gán cả gói cho từng sinh viên.
|
||||
Chọn <strong>nhiều PDF đề chính</strong> cùng lúc để tạo gói hàng loạt. Sau đó bổ sung <strong>tài nguyên kèm</strong> (ảnh, zip, doc…) cho từng gói bên dưới.
|
||||
</p>
|
||||
{!canModifyPapers && (
|
||||
<p className="config-card-desc" style={{ color: 'var(--danger)' }}>
|
||||
@@ -761,44 +1011,61 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem', marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Tên gói đề (VD: Gói A)"
|
||||
placeholder="Tên gói đề (tùy chọn, chỉ khi tạo 1 PDF)"
|
||||
value={paperTitle}
|
||||
onChange={(e) => setPaperTitle(e.target.value)}
|
||||
/>
|
||||
<label className="login-field">
|
||||
<span>PDF đề chính (bắt buộc)</span>
|
||||
<span>PDF đề chính (chọn nhiều file)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => setPackagePdf(e.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field">
|
||||
<span>Tài nguyên kèm (tùy chọn, many files)</span>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => setPackageResources(Array.from(e.target.files || []))}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []).filter((f) => /\.pdf$/i.test(f.name));
|
||||
if (!files.length) {
|
||||
setPackagePdfs([]);
|
||||
return;
|
||||
}
|
||||
setPackagePdfs(files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{packageResources.length > 0 && (
|
||||
{packagePdfs.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<p className="config-card-desc" style={{ margin: 0 }}>
|
||||
{packageResources.length} tài nguyên: {packageResources.map((f) => f.name).join(', ')}
|
||||
Đã chọn {packagePdfs.length} PDF — mỗi file sẽ thành 1 gói đề:
|
||||
</p>
|
||||
<ul className="config-card-desc" style={{ margin: 0, paddingLeft: '1.1rem' }}>
|
||||
{packagePdfs.map((f, i) => (
|
||||
<li key={`${f.name}-${i}`} style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<span>📄 {paperTitle.trim() && packagePdfs.length === 1 ? paperTitle.trim() : f.name.replace(/\.pdf$/i, '')}</span>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removePackagePdf(i)}>
|
||||
Bỏ
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
disabled={creatingPackage || !packagePdf}
|
||||
onClick={createPackage}
|
||||
disabled={creatingPackage || packagePdfs.length === 0}
|
||||
onClick={createPackagesBulk}
|
||||
>
|
||||
{creatingPackage ? 'Đang tạo...' : 'Tạo gói đề'}
|
||||
{creatingPackage
|
||||
? 'Đang tạo...'
|
||||
: packagePdfs.length > 1
|
||||
? `Tạo ${packagePdfs.length} gói đề`
|
||||
: 'Tạo gói đề'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{papers.length === 0 ? (
|
||||
<p className="config-card-desc">Chưa có gói đề. Tạo gói gồm PDF + tài nguyên.</p>
|
||||
<p className="config-card-desc">Chưa có gói đề. Chọn nhiều PDF để tạo hàng loạt.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{papers.map((p) => (
|
||||
@@ -861,87 +1128,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="right-panel workspace-content-panel">
|
||||
<div className="workspace-panel-toolbar">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
<div className={`right-panel workspace-content-panel${activeSubTab === 'grid' ? ' workspace-content-panel--grid' : ''}`}>
|
||||
<div className="workspace-panel-toolbar ew-toolbar">
|
||||
<div className="ew-toolbar-left">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary workspace-config-toggle ${configOpen ? 'active' : ''}`}
|
||||
className={`btn btn-secondary workspace-config-toggle ew-config-toggle ${configOpen ? 'active' : ''}`}
|
||||
onClick={() => setConfigOpen((v) => !v)}
|
||||
>
|
||||
<IconSettings size={14} />
|
||||
{configOpen ? 'Ẩn cấu hình' : 'Cấu hình phòng thi'}
|
||||
</button>
|
||||
<div className="tab-btn-group">
|
||||
<div className="ew-seg" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'roster' ? 'active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeSubTab === 'roster'}
|
||||
className={`ew-seg-btn ${activeSubTab === 'roster' ? 'ew-seg-btn--active' : ''}`}
|
||||
onClick={() => setActiveSubTab('roster')}
|
||||
>
|
||||
Sơ đồ sinh viên
|
||||
<IconMap size={14} />
|
||||
Sơ đồ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'grid' ? 'active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeSubTab === 'grid'}
|
||||
className={`ew-seg-btn ${activeSubTab === 'grid' ? 'ew-seg-btn--active' : ''}`}
|
||||
onClick={() => setActiveSubTab('grid')}
|
||||
>
|
||||
Giám sát camera 🖥️
|
||||
<IconMonitor size={14} />
|
||||
Giám sát
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'detail' ? 'active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeSubTab === 'detail'}
|
||||
className={`ew-seg-btn ${activeSubTab === 'detail' ? 'ew-seg-btn--active' : ''}`}
|
||||
onClick={() => setActiveSubTab('detail')}
|
||||
>
|
||||
<IconList size={14} />
|
||||
Chi tiết thi
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'submissions' ? 'active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeSubTab === 'submissions'}
|
||||
className={`ew-seg-btn ${activeSubTab === 'submissions' ? 'ew-seg-btn--active' : ''}`}
|
||||
onClick={() => setActiveSubTab('submissions')}
|
||||
>
|
||||
<IconPackage size={14} />
|
||||
Bài nộp ({submissions.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab-sub-btn ${activeSubTab === 'violations' ? 'active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeSubTab === 'violations'}
|
||||
className={`ew-seg-btn ${activeSubTab === 'violations' ? 'ew-seg-btn--active' : ''}`}
|
||||
onClick={() => setActiveSubTab('violations')}
|
||||
>
|
||||
<IconShield size={14} />
|
||||
Vi phạm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div className="ew-toolbar-right">
|
||||
{activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
|
||||
<div className="search-input-wrapper">
|
||||
<div className="ew-search-wrapper">
|
||||
<span className="ew-search-icon"><IconSearch size={14} /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
type="search"
|
||||
className="search-input ew-search-input"
|
||||
placeholder="Tìm sinh viên..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon">🔍</span>
|
||||
</div>
|
||||
)}
|
||||
{prepEditable && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
className="btn btn-primary btn-sm ew-icon-btn"
|
||||
onClick={() => { setStudentPickerOpen(true); setSearchQ(''); setSearchHits([]); setSelectedPickerRkIds([]); }}
|
||||
>
|
||||
+ Thêm sinh viên
|
||||
<IconPlus size={14} />
|
||||
Thêm sinh viên
|
||||
</button>
|
||||
)}
|
||||
{papers.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||
📄 Xem đề ({papers.length})
|
||||
<button type="button" className="btn btn-secondary btn-sm ew-icon-btn" onClick={() => setPapersModalOpen(true)}>
|
||||
<IconExam size={13} />
|
||||
Xem đề ({papers.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
|
||||
</div>
|
||||
<div className="ew-toolbar-divider" />
|
||||
|
||||
<div className="workspace-panel-body workspace-panel-fill">
|
||||
{activeSubTab === 'roster' ? (
|
||||
@@ -970,8 +1253,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="session-logs-panel">
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{students.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📋</div>
|
||||
<div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
|
||||
<div className="ew-empty-icon"><IconInbox size={36} /></div>
|
||||
<h2>Chưa có sinh viên</h2>
|
||||
<p>Thêm sinh viên để xem chi tiết đề gán và trạng thái nộp bài.</p>
|
||||
</div>
|
||||
@@ -1023,14 +1306,22 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<button type="button" className="btn btn-secondary btn-sm" style={{ marginLeft: '0.35rem' }} onClick={() => openStudentChat(s)}>
|
||||
Nhắn tin
|
||||
</button>
|
||||
{prepEditable && (
|
||||
{canRemoveStudents && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
style={{ marginLeft: '0.35rem' }}
|
||||
onClick={() => apiExam.removeStudent(examId, s.studentRkId).then(load)}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Xóa sinh viên "${s.fullName}" khỏi phòng thi?`)) return;
|
||||
try {
|
||||
await apiExam.removeStudent(examId, s.studentRkId);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || 'Xóa sinh viên thất bại');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Xóa
|
||||
Xóa khỏi phòng
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
@@ -1051,10 +1342,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
{papers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
className="btn btn-secondary btn-sm ew-icon-btn"
|
||||
onClick={() => setPapersModalOpen(true)}
|
||||
>
|
||||
📄 Xem đề phòng thi
|
||||
<IconExam size={13} />
|
||||
Xem đề phòng thi
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1063,7 +1355,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
disabled={bundlingSubs}
|
||||
onClick={downloadAllSubs}
|
||||
>
|
||||
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
|
||||
{bundlingSubs ? 'Đang gói ZIP...' : 'Tải tất cả (ZIP gộp)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1098,8 +1390,8 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
)}
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
<div className="empty-state-icon">📦</div>
|
||||
<div className="empty-state ew-empty" style={{ minHeight: '300px' }}>
|
||||
<div className="ew-empty-icon"><IconPackage size={36} /></div>
|
||||
<h2>Chưa có bài nộp</h2>
|
||||
<p>Sinh viên nộp bài qua app Simple Care sẽ hiện tại đây.</p>
|
||||
</div>
|
||||
@@ -1223,6 +1515,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
student={selectedStudent}
|
||||
isOnline={onlineIds.includes(selectedStudent.rkId)}
|
||||
onClose={() => setSelectedStudent(null)}
|
||||
initialShowProctor={activeSubTab === 'grid' || activeSubTab === 'roster'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1333,17 +1626,17 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="exam-student-hits">
|
||||
{searchMode === 'single' && searchQ.trim() === '' ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>🔍</span>
|
||||
<span className="ew-empty-icon"><IconSearch size={28} /></span>
|
||||
<p>Gõ ít nhất vài ký tự để tìm sinh viên.</p>
|
||||
</div>
|
||||
) : searchMode === 'bulk' && searchHits.length === 0 ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>📋</span>
|
||||
<span className="ew-empty-icon"><IconList size={28} /></span>
|
||||
<p>Paste danh sách và bấm "Tìm hàng loạt" để hiển thị kết quả.</p>
|
||||
</div>
|
||||
) : searchHits.length === 0 ? (
|
||||
<div className="exam-student-hits-empty">
|
||||
<span>📭</span>
|
||||
<span className="ew-empty-icon"><IconInbox size={28} /></span>
|
||||
<p>Không tìm thấy sinh viên phù hợp.</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1445,6 +1738,103 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
onSelect={applyTemplate}
|
||||
allowedApps={allowedApps}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.ew-header { padding: 0.7rem 1.1rem; gap: 0.65rem; }
|
||||
.ew-header-left { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
|
||||
.ew-brand-icon {
|
||||
width: 40px; height: 40px; border-radius: var(--radius-sm);
|
||||
background: var(--accent-light); color: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.ew-title-block { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
|
||||
.ew-header .workspace-class-title { font-size: 1.2rem; font-weight: 700; margin: 0; line-height: 1.25; }
|
||||
.ew-header .class-badge-container { margin-top: 0.15rem; gap: 0.35rem; }
|
||||
.ew-header-right { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.ew-status-panel { gap: 0.75rem; padding: 0.4rem 0.85rem; }
|
||||
.ew-status-label-group { display: flex; flex-direction: column; }
|
||||
.ew-meta-label {
|
||||
font-size: 0.68rem; color: var(--text-muted); font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.ew-status-value { font-size: 0.88rem; font-weight: 700; }
|
||||
.ew-status-actions { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.ew-extend-row { display: flex; gap: 0.3rem; flex-wrap: wrap; align-items: center; }
|
||||
.ew-online-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 0.1rem; }
|
||||
.ew-online-value {
|
||||
display: flex; align-items: center; gap: 0.3rem; font-size: 1.1rem !important;
|
||||
}
|
||||
.ew-online-total { color: var(--text-muted); font-size: 0.82rem; font-weight: 500; }
|
||||
|
||||
.ew-drawer-title {
|
||||
display: inline-flex; align-items: center; gap: 0.45rem;
|
||||
font-weight: 700; font-size: 0.92rem; color: var(--text-primary);
|
||||
}
|
||||
.ew-drawer-close {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border: none; background: var(--bg-subtle);
|
||||
border-radius: var(--radius-sm); color: var(--text-secondary); cursor: pointer;
|
||||
}
|
||||
.ew-drawer-close:hover { background: var(--border-color); color: var(--text-primary); }
|
||||
|
||||
.workspace-content-panel.right-panel {
|
||||
padding: 0.65rem 0.85rem 0.85rem; gap: 0;
|
||||
}
|
||||
.workspace-content-panel--grid { padding-top: 0.55rem; }
|
||||
.ew-toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-wrap: wrap; gap: 0.55rem 0.75rem; flex-shrink: 0;
|
||||
}
|
||||
.ew-toolbar-left, .ew-toolbar-right {
|
||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.ew-config-toggle {
|
||||
display: inline-flex !important; align-items: center; gap: 0.35rem;
|
||||
padding: 0.38rem 0.85rem !important; font-size: 0.8rem !important; font-weight: 600 !important;
|
||||
}
|
||||
.ew-toolbar-divider {
|
||||
margin: 0.5rem 0 0.35rem; border-top: 1px solid var(--border-light);
|
||||
opacity: 0.45; flex-shrink: 0;
|
||||
}
|
||||
.workspace-content-panel--grid .ew-toolbar-divider { margin: 0.4rem 0 0; opacity: 0.35; }
|
||||
|
||||
.ew-seg {
|
||||
display: flex; background: var(--bg-subtle); border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm); padding: 0.18rem; gap: 0.15rem; flex-wrap: wrap;
|
||||
}
|
||||
.ew-seg-btn {
|
||||
display: flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.65rem;
|
||||
font-size: 0.78rem; font-weight: 600; background: transparent; color: var(--text-secondary);
|
||||
border: none; border-radius: 4px; cursor: pointer; white-space: nowrap;
|
||||
font-family: var(--font-sans); transition: background 0.14s, color 0.14s;
|
||||
}
|
||||
.ew-seg-btn:hover:not(.ew-seg-btn--active) {
|
||||
background: var(--border-light); color: var(--text-primary);
|
||||
}
|
||||
.ew-seg-btn--active {
|
||||
background: #fff; color: var(--accent); box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.ew-search-wrapper { position: relative; max-width: 220px; width: 100%; }
|
||||
.ew-search-icon {
|
||||
position: absolute; left: 0.6rem; top: 50%; transform: translateY(-50%);
|
||||
color: var(--text-muted); display: flex; pointer-events: none;
|
||||
}
|
||||
.ew-search-input { padding: 0.38rem 0.8rem 0.38rem 2rem !important; font-size: 0.82rem !important; width: 100%; }
|
||||
.ew-icon-btn {
|
||||
display: inline-flex !important; align-items: center; gap: 0.3rem;
|
||||
}
|
||||
|
||||
.ew-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.45rem; }
|
||||
.ew-empty-icon { display: flex; color: var(--text-muted); opacity: 0.55; }
|
||||
.exam-student-hits-empty .ew-empty-icon { margin-bottom: 0.25rem; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.ew-header { padding: 0.65rem 0.85rem; }
|
||||
.ew-header .workspace-class-title { font-size: 1.05rem; }
|
||||
.ew-seg-btn { padding: 0.28rem 0.5rem; font-size: 0.74rem; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,13 +11,13 @@ function fmtTime(iso: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(st: string) {
|
||||
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
|
||||
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
|
||||
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
|
||||
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
|
||||
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
|
||||
return { text: st, cls: 'badge-muted' };
|
||||
function statusMeta(st: string) {
|
||||
if (st === 'draft') return { text: 'Tạm thời', tone: 'muted' };
|
||||
if (st === 'ready') return { text: 'Sẵn sàng', tone: 'info' };
|
||||
if (st === 'active') return { text: 'Đang thi', tone: 'active' };
|
||||
if (st === 'cancelled') return { text: 'Đã hủy', tone: 'warn' };
|
||||
if (st === 'ended') return { text: 'Đã kết thúc', tone: 'muted' };
|
||||
return { text: st, tone: 'muted' };
|
||||
}
|
||||
|
||||
function toLocalInput(iso?: string) {
|
||||
@@ -32,9 +32,70 @@ function localInputToISO(v: string) {
|
||||
return new Date(v).toISOString();
|
||||
}
|
||||
|
||||
type RoomScope = 'mine' | 'all';
|
||||
type IconProps = { size?: number; filled?: boolean };
|
||||
|
||||
const IconClipboard = ({ size = 22 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" />
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
<path d="M9 12h6M9 16h4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStar = ({ size = 14, filled }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m12 3.5 2.6 5.3 5.8.8-4.2 4.1 1 5.8L12 16.8l-5.2 2.7 1-5.8-4.2-4.1 5.8-.8L12 3.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCalendar = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 11h18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconUsers = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconOpen = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 6 6 6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 36 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ExamsTab: React.FC = () => {
|
||||
const { staff } = useAuth();
|
||||
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||
const myStaffId = staff?.id ?? 0;
|
||||
|
||||
const [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -44,6 +105,7 @@ export const ExamsTab: React.FC = () => {
|
||||
const [end, setEnd] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -61,20 +123,28 @@ export const ExamsTab: React.FC = () => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const scopedRooms = useMemo(() => {
|
||||
if (roomScope === 'all' || !myStaffId) return rooms;
|
||||
return rooms.filter((r) => Number(r.createdByStaffId) === myStaffId);
|
||||
}, [rooms, roomScope, myStaffId]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = rooms.filter((r) => {
|
||||
const active = scopedRooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||
const upcoming = scopedRooms.filter((r) => {
|
||||
const st = r.displayStatus || r.status;
|
||||
return st === 'ready' || st === 'draft';
|
||||
}).length;
|
||||
return { total: rooms.length, active, upcoming };
|
||||
}, [rooms]);
|
||||
return { total: scopedRooms.length, active, upcoming };
|
||||
}, [scopedRooms]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rooms;
|
||||
return rooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [rooms, search]);
|
||||
if (!q) return scopedRooms;
|
||||
return scopedRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [scopedRooms, search]);
|
||||
|
||||
const canDeleteRoom = (r: ExamRoomItem) =>
|
||||
isSuperAdmin || (myStaffId > 0 && Number(r.createdByStaffId) === myStaffId);
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim() || !start || !end) return;
|
||||
@@ -99,7 +169,11 @@ export const ExamsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeleteRoom = async (roomId: number, roomName: string) => {
|
||||
if (window.confirm(`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`)) {
|
||||
if (
|
||||
window.confirm(
|
||||
`Bạn có chắc chắn muốn xóa hoàn toàn phòng thi "${roomName}" không? Hành động này sẽ xóa sạch dữ liệu phòng thi, các bài nộp, và không thể khôi phục.`
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await apiExam.remove(roomId);
|
||||
alert('Đã xóa phòng thi thành công!');
|
||||
@@ -112,20 +186,37 @@ export const ExamsTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="tab-page exams-page">
|
||||
<header className="page-header page-header--row">
|
||||
<div>
|
||||
<h1 className="page-title">Danh sách phòng thi</h1>
|
||||
<p className="page-desc">Tạo phòng, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1 className="page-title-heading">
|
||||
<span className="page-title-icon" aria-hidden>
|
||||
<IconClipboard />
|
||||
</span>
|
||||
Danh sách phòng thi
|
||||
</h1>
|
||||
<p>
|
||||
{roomScope === 'mine'
|
||||
? 'Mặc định chỉ hiện phòng thi do bạn tạo — chuyển sang xem tất cả khi cần'
|
||||
: 'Đang xem toàn bộ phòng thi trong hệ thống'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="exams-header-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
+ Tạo phòng thi
|
||||
<IconPlus />
|
||||
Tạo phòng thi
|
||||
</button>
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-body" style={{ gap: '0.85rem' }}>
|
||||
<div className="exams-stats-row">
|
||||
<div className="learning-stat">
|
||||
<span className="learning-stat-value">{stats.total}</span>
|
||||
<span className="learning-stat-label">Tổng phòng</span>
|
||||
<span className="learning-stat-label">
|
||||
{roomScope === 'mine' ? 'Phòng của tôi' : 'Tổng phòng'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="learning-stat learning-stat--accent">
|
||||
<span className="learning-stat-value">{stats.active}</span>
|
||||
@@ -137,51 +228,174 @@ export const ExamsTab: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exams-toolbar">
|
||||
<div className="content-card learning-toolbar-card exams-toolbar-card">
|
||||
<div className="learning-toolbar exams-toolbar-inner">
|
||||
<div className="learning-seg" role="group" aria-label="Phạm vi phòng thi">
|
||||
<button
|
||||
type="button"
|
||||
className={`learning-seg-btn${roomScope === 'mine' ? ' learning-seg-btn--active' : ''}`}
|
||||
onClick={() => setRoomScope('mine')}
|
||||
>
|
||||
<IconStar size={13} filled={roomScope === 'mine'} />
|
||||
<span>Phòng của tôi</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`learning-seg-btn${roomScope === 'all' ? ' learning-seg-btn--active' : ''}`}
|
||||
onClick={() => setRoomScope('all')}
|
||||
>
|
||||
<span>Xem tất cả</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search exams-search"
|
||||
className="app-pool-search learning-search exams-search"
|
||||
placeholder="Tìm theo tên phòng thi..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-scroll">
|
||||
<div className="content-card" style={{ padding: 0 }}>
|
||||
{loading ? (
|
||||
<div className="exams-empty-panel">
|
||||
<div className="sync-spinner" style={{ width: 36, height: 36, borderWidth: 3 }} />
|
||||
<p>Đang tải danh sách phòng thi...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="exams-empty-panel">
|
||||
<div className="exams-empty-icon">
|
||||
{search.trim() ? <IconSearch size={32} /> : <IconInbox />}
|
||||
</div>
|
||||
<p>
|
||||
{search.trim()
|
||||
? 'Không tìm thấy phòng thi phù hợp.'
|
||||
: roomScope === 'mine'
|
||||
? 'Bạn chưa tạo phòng thi nào.'
|
||||
: 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
|
||||
</p>
|
||||
{!search.trim() && (
|
||||
<div className="exams-empty-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<IconPlus />
|
||||
Tạo phòng thi
|
||||
</button>
|
||||
{roomScope === 'mine' && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setRoomScope('all')}>
|
||||
Xem tất cả
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="exams-card-grid">
|
||||
{filtered.map((r) => {
|
||||
const st = statusMeta(r.displayStatus || r.status);
|
||||
const mine = myStaffId > 0 && Number(r.createdByStaffId) === myStaffId;
|
||||
const isActive = (r.displayStatus || r.status) === 'active';
|
||||
return (
|
||||
<article
|
||||
key={r.id}
|
||||
className={`exam-list-card${isActive ? ' exam-list-card--active' : ''}`}
|
||||
>
|
||||
<header className="exam-list-card-head">
|
||||
<div className="exam-list-card-info">
|
||||
<h3 className="exam-list-card-title" title={r.name}>{r.name}</h3>
|
||||
<div className="exam-list-card-meta-block">
|
||||
<div className="exam-meta-row">
|
||||
<IconCalendar />
|
||||
<span>{fmtTime(r.startTime)}</span>
|
||||
<span className="exam-list-card-sep">→</span>
|
||||
<span>{fmtTime(r.endTime)}</span>
|
||||
</div>
|
||||
<div className="exam-meta-row exam-meta-row--secondary">
|
||||
<IconUsers />
|
||||
<span>
|
||||
{r.studentCount} SV · {r.paperCount} đề
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exam-list-card-badges">
|
||||
{mine && <span className="exam-tag exam-tag--mine">Của tôi</span>}
|
||||
<span className={`exam-tag exam-tag--${st.tone}`}>{st.text}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<footer className="exam-list-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => openExam(r.id, r.name)}
|
||||
>
|
||||
Mở phòng thi
|
||||
<IconOpen />
|
||||
</button>
|
||||
{canDeleteRoom(r) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm exam-btn-danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRoom(r.id, r.name);
|
||||
}}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||
<div className="modal-container" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header class-picker-header">
|
||||
<div>
|
||||
<h2 className="modal-title">Tạo phòng thi mới</h2>
|
||||
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">×</button>
|
||||
<p className="class-picker-subtitle">
|
||||
Đặt tên và khung giờ thi. Sau khi tạo sẽ mở workspace để cấu hình đề và sinh viên.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Tên phòng thi</span>
|
||||
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body class-picker-body exam-create-body">
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Tên phòng thi</span>
|
||||
<input
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="VD: Thi cuối kỳ Java"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Bắt đầu</span>
|
||||
<div className="exam-field-grid">
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Bắt đầu</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
|
||||
className="search-input exam-datetime"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Kết thúc</span>
|
||||
<label className="exam-field">
|
||||
<span className="exam-field-label">Kết thúc</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="search-input"
|
||||
style={{ width: '100%', boxSizing: 'border-box', padding: '0.5rem 0.75rem' }}
|
||||
className="search-input exam-datetime"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
/>
|
||||
@@ -192,7 +406,12 @@ export const ExamsTab: React.FC = () => {
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
|
||||
Hủy
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !name.trim() || !start || !end}
|
||||
onClick={create}
|
||||
>
|
||||
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -200,73 +419,327 @@ export const ExamsTab: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="system-empty">Đang tải...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="system-empty">
|
||||
{search.trim() ? 'Không tìm thấy phòng thi phù hợp.' : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="exams-card-grid">
|
||||
{filtered.map((r) => {
|
||||
const st = statusLabel(r.displayStatus || r.status);
|
||||
return (
|
||||
<article key={r.id} className="exam-list-card">
|
||||
<div className="exam-list-card-head">
|
||||
<h3>{r.name}</h3>
|
||||
<span className={`badge ${st.cls}`}>{st.text}</span>
|
||||
</div>
|
||||
<div className="exam-list-card-meta">
|
||||
<div>
|
||||
<span className="exam-list-card-label">Thời gian</span>
|
||||
<span>{fmtTime(r.startTime)}</span>
|
||||
<span className="exam-list-card-sep">→</span>
|
||||
<span>{fmtTime(r.endTime)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="exam-list-card-label">Quy mô</span>
|
||||
<span>{r.studentCount} sinh viên · {r.paperCount} đề</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.25rem' }}>
|
||||
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)} style={{ flex: 1, margin: 0 }}>
|
||||
Mở phòng thi
|
||||
</button>
|
||||
{isSuperAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRoom(r.id, r.name);
|
||||
}}
|
||||
style={{
|
||||
borderColor: 'var(--danger)',
|
||||
color: 'var(--danger)',
|
||||
backgroundColor: 'transparent',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontWeight: 600,
|
||||
transition: 'var(--transition)',
|
||||
margin: 0
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--danger)';
|
||||
e.currentTarget.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--danger)';
|
||||
}}
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.page-title-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.page-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exams-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.exams-header-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.exams-toolbar-card {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
.exams-toolbar-inner {
|
||||
margin: 0;
|
||||
}
|
||||
.exams-search {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
.exams-empty-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 2.75rem 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.exams-empty-panel p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
max-width: 36ch;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.exams-empty-icon {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
}
|
||||
.exams-empty-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.exams-empty-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.exams-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
padding-bottom: 4.75rem;
|
||||
}
|
||||
|
||||
.exam-list-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
min-height: 0;
|
||||
}
|
||||
.exam-list-card:hover {
|
||||
border-color: #c5cdd8;
|
||||
box-shadow: 0 3px 12px rgba(26, 35, 50, 0.08);
|
||||
}
|
||||
.exam-list-card--active {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: rgba(187, 33, 38, 0.03);
|
||||
}
|
||||
|
||||
.exam-list-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
.exam-list-card-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.exam-list-card-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.exam-list-card-meta-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.exam-meta-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.exam-meta-row svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.exam-meta-row--secondary {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.exam-list-card-sep {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.exam-list-card-badges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exam-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.exam-tag--mine {
|
||||
background: rgba(234, 179, 8, 0.14);
|
||||
color: #a16207;
|
||||
border-color: rgba(234, 179, 8, 0.35);
|
||||
}
|
||||
.exam-tag--active {
|
||||
background: rgba(13, 159, 110, 0.12);
|
||||
color: #0b7a55;
|
||||
border-color: rgba(13, 159, 110, 0.28);
|
||||
}
|
||||
.exam-tag--info {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
.exam-tag--warn {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #b45309;
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.exam-tag--muted {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.exam-list-card-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.exam-list-card-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
height: 30px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
border-radius: 7px;
|
||||
line-height: 1;
|
||||
}
|
||||
.exam-btn-danger {
|
||||
color: #b91c1c !important;
|
||||
border-color: #fecaca !important;
|
||||
background: #fef2f2 !important;
|
||||
flex: 0 0 auto !important;
|
||||
min-width: 52px;
|
||||
}
|
||||
.exam-btn-danger:hover {
|
||||
background: #fee2e2 !important;
|
||||
border-color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
.exam-create-modal {
|
||||
max-width: 520px !important;
|
||||
}
|
||||
.exam-create-modal .class-picker-header {
|
||||
align-items: flex-start !important;
|
||||
padding: 1.1rem 1.35rem !important;
|
||||
}
|
||||
.exam-create-modal .class-picker-subtitle {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
max-width: 42ch;
|
||||
}
|
||||
.exam-create-modal .class-picker-body {
|
||||
padding: 1rem 1.35rem 1.15rem !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.exam-create-body {
|
||||
gap: 0.9rem !important;
|
||||
}
|
||||
.exam-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.exam-field-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.exam-field .search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.exam-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.exam-datetime {
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.exams-header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.exams-header-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.learning-seg {
|
||||
width: 100%;
|
||||
}
|
||||
.learning-seg-btn {
|
||||
flex: 1;
|
||||
}
|
||||
.exams-stats-row {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.exams-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.exam-list-card-actions .btn {
|
||||
height: 32px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.exams-stats-row {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.exam-field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.exam-list-card-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.exam-btn-danger {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,135 +1,75 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { getWsUrl } from '../api';
|
||||
import { StudentStreamImage } from './StudentStreamImage';
|
||||
|
||||
interface ProctorStreamPanelsProps {
|
||||
studentId: number;
|
||||
layout?: 'default' | 'focus';
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconMinus = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPlus = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMaximize = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMinimize = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCamera = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCameraOff = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
<path d="M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v7" />
|
||||
<path d="M9.4 9.4A4 4 0 0 0 12 17a4 4 0 0 0 3.6-5.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
|
||||
|
||||
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
studentId,
|
||||
layout = 'focus',
|
||||
}) => {
|
||||
const [screenFrame, setScreenFrame] = useState<string | null>(null);
|
||||
const [webcamFrame, setWebcamFrame] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showWebcam, setShowWebcam] = useState(true);
|
||||
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
|
||||
const [screenZoomIdx, setScreenZoomIdx] = useState(2);
|
||||
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
const hasOpened = useRef(false);
|
||||
const hasFrames = useRef(false);
|
||||
const screenPanelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const screenZoom = ZOOM_STEPS[screenZoomIdx];
|
||||
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
|
||||
|
||||
useEffect(() => {
|
||||
const wsUrl = getWsUrl('/ws?role=teacher');
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
intentionalClose.current = false;
|
||||
hasOpened.current = false;
|
||||
hasFrames.current = false;
|
||||
setStreaming(false);
|
||||
setErrorMessage(null);
|
||||
setScreenFrame(null);
|
||||
setWebcamFrame(null);
|
||||
|
||||
const markStreaming = () => {
|
||||
if (!hasFrames.current) {
|
||||
hasFrames.current = true;
|
||||
setStreaming(true);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearPing = () => {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (intentionalClose.current) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
attempt = 0;
|
||||
hasOpened.current = true;
|
||||
setStreaming(true);
|
||||
setErrorMessage(null);
|
||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
||||
clearPing();
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
|
||||
}
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.event === 'client:pong') return;
|
||||
if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) {
|
||||
setScreenFrame(msg.data.imageBuffer);
|
||||
markStreaming();
|
||||
} else if (msg.event === 'teacher:webcam-stream-frame' && msg.data.studentId === studentId) {
|
||||
setWebcamFrame(msg.data.imageBuffer);
|
||||
markStreaming();
|
||||
} else if (msg.event === 'teacher:stream-stopped' && msg.data.studentId === studentId) {
|
||||
setScreenFrame(null);
|
||||
setWebcamFrame(null);
|
||||
setStreaming(false);
|
||||
setErrorMessage('Sinh viên đã dừng stream');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing WS frame:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearPing();
|
||||
if (intentionalClose.current) return;
|
||||
setStreaming(false);
|
||||
if (!hasOpened.current && !hasFrames.current) {
|
||||
setErrorMessage('Không thể kết nối máy chủ giám sát');
|
||||
} else {
|
||||
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
|
||||
}
|
||||
const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000);
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
clearPing();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
||||
}
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [studentId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFsChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
|
||||
@@ -154,12 +94,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
|
||||
const zoomIn = (target: 'screen' | 'webcam') => {
|
||||
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
|
||||
setter(i => Math.min(i + 1, ZOOM_STEPS.length - 1));
|
||||
setter((i) => Math.min(i + 1, ZOOM_STEPS.length - 1));
|
||||
};
|
||||
|
||||
const zoomOut = (target: 'screen' | 'webcam') => {
|
||||
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
|
||||
setter(i => Math.max(i - 1, 0));
|
||||
setter((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
|
||||
const zoomReset = (target: 'screen' | 'webcam') => {
|
||||
@@ -169,12 +109,19 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
|
||||
const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
|
||||
<div className="panel-toolbar">
|
||||
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}>−</button>
|
||||
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}>
|
||||
<IconMinus size={14} />
|
||||
</button>
|
||||
<span className="proctor-zoom-label">{Math.round(zoom * 100)}%</span>
|
||||
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>+</button>
|
||||
<button type="button" className="proctor-tool-btn" title="Về 100%" onClick={() => zoomReset(target)}>1:1</button>
|
||||
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>
|
||||
<IconPlus size={14} />
|
||||
</button>
|
||||
<button type="button" className="proctor-tool-btn proctor-tool-btn-label" title="Về 100%" onClick={() => zoomReset(target)}>
|
||||
1:1
|
||||
</button>
|
||||
{onFs && (
|
||||
<button type="button" className="proctor-tool-btn proctor-tool-btn-wide" title="Toàn màn hình" onClick={onFs}>
|
||||
{isFullscreen ? <IconMinimize size={13} /> : <IconMaximize size={13} />}
|
||||
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
|
||||
</button>
|
||||
)}
|
||||
@@ -184,24 +131,22 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
return (
|
||||
<div className="proctor-stream-wrap">
|
||||
<div className="proctor-stream-toolbar">
|
||||
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
|
||||
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
|
||||
<span className="status-pill connected">
|
||||
<span className="status-pill-dot" />
|
||||
Đang phát (HTTP)
|
||||
</span>
|
||||
<div className="proctor-stream-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
|
||||
onClick={() => setShowWebcam(v => !v)}
|
||||
onClick={() => setShowWebcam((v) => !v)}
|
||||
>
|
||||
{showWebcam ? <IconCameraOff size={13} /> : <IconCamera size={13} />}
|
||||
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage && !screenFrame && !webcamFrame && (
|
||||
<div className="alert-error proctor-stream-error">{errorMessage}</div>
|
||||
)}
|
||||
|
||||
<div className={`proctor-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
|
||||
<div
|
||||
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
|
||||
@@ -213,17 +158,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
|
||||
<div className="proctor-zoom-viewport">
|
||||
{screenFrame ? (
|
||||
<img
|
||||
src={screenFrame}
|
||||
alt="Màn hình sinh viên"
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="screen"
|
||||
className="live-frame screen-img"
|
||||
style={{ transform: `scale(${screenZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,17 +176,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||
</div>
|
||||
<div className="panel-body webcam-body">
|
||||
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
|
||||
{webcamFrame ? (
|
||||
<img
|
||||
src={webcamFrame}
|
||||
alt="Webcam sinh viên"
|
||||
<StudentStreamImage
|
||||
studentId={studentId}
|
||||
kind="webcam"
|
||||
className="live-frame webcam-img"
|
||||
style={{ transform: `scale(${webcamZoom})` }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
118
management/src/components/RescideAnnouncePopup.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import {
|
||||
isRescidePopupHidden,
|
||||
RESCIDE_DEFAULT_DOWNLOADS,
|
||||
RESCIDE_FEATURES,
|
||||
setRescidePopupHidden,
|
||||
} from '../rescide';
|
||||
import { navigate } from '../navigation';
|
||||
|
||||
type Props = {
|
||||
/** Khi true, không hiện popup (ví dụ đang ở workspace lớp/thi). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RescideAnnouncePopup: React.FC<Props> = ({ disabled }) => {
|
||||
const { staff } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !staff?.email) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(!isRescidePopupHidden(staff.email));
|
||||
}, [staff?.email, disabled]);
|
||||
|
||||
if (!open || !staff?.email) return null;
|
||||
|
||||
const dismissOnce = () => setOpen(false);
|
||||
|
||||
const dismissForever = () => {
|
||||
setRescidePopupHidden(staff.email, true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const goTab = () => {
|
||||
setOpen(false);
|
||||
navigate('rescide');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" style={{ zIndex: 1200 }} onClick={dismissOnce}>
|
||||
<div
|
||||
className="modal-container"
|
||||
style={{ maxWidth: 520, width: '92%' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>
|
||||
Thông báo: RESCIDE IDE
|
||||
</h2>
|
||||
<button type="button" className="modal-close-btn" aria-label="Đóng" onClick={dismissOnce}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
|
||||
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: '1rem' }}>
|
||||
<strong style={{ color: 'var(--text-primary)' }}>RESCIDE</strong> — IDE thi thực hành đang mở test thử để
|
||||
đánh giá và cải thiện chức năng. Mục đích: môi trường IDE chuyên cho kỳ thi thực hành, chống gian lận cùng
|
||||
RAIA DESKTOP.
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0 0 1.25rem',
|
||||
paddingLeft: '1.15rem',
|
||||
fontSize: '0.875rem',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
{RESCIDE_FEATURES.map((f) => (
|
||||
<li key={f} style={{ marginBottom: '0.35rem' }}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
{RESCIDE_DEFAULT_DOWNLOADS.map((d) => (
|
||||
<a
|
||||
key={d.downloadUrl}
|
||||
href={d.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-secondary"
|
||||
style={{ justifyContent: 'space-between', textDecoration: 'none', width: '100%' }}
|
||||
>
|
||||
<span>{d.name}</span>
|
||||
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>Tải</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
padding: '1rem 1.5rem',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={dismissForever}>
|
||||
Ẩn với tài khoản này
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={goTab}>
|
||||
Xem chi tiết
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={dismissOnce}>
|
||||
Đã hiểu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
182
management/src/components/RescideTab.tsx
Normal file
182
management/src/components/RescideTab.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, type AppDownloadItem } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import {
|
||||
isRescideManager,
|
||||
isRescidePopupHidden,
|
||||
RESCIDE_DEFAULT_DOWNLOADS,
|
||||
RESCIDE_FEATURES,
|
||||
setRescidePopupHidden,
|
||||
type RescideDownload,
|
||||
} from '../rescide';
|
||||
import { navigate } from '../navigation';
|
||||
|
||||
function mergeDownloads(fromApi: AppDownloadItem[]): RescideDownload[] {
|
||||
const rescide = fromApi.filter((a) => a.name.toUpperCase().includes('RESCIDE'));
|
||||
if (rescide.length > 0) {
|
||||
return rescide.map((a) => ({
|
||||
name: a.name,
|
||||
description: a.description || '',
|
||||
downloadUrl: a.downloadUrl,
|
||||
platform: a.platform || '',
|
||||
}));
|
||||
}
|
||||
return RESCIDE_DEFAULT_DOWNLOADS;
|
||||
}
|
||||
|
||||
export const RescideTab: React.FC = () => {
|
||||
const { staff } = useAuth();
|
||||
const canManage = isRescideManager(staff?.email);
|
||||
const [downloads, setDownloads] = useState<RescideDownload[]>(RESCIDE_DEFAULT_DOWNLOADS);
|
||||
const [popupHidden, setPopupHidden] = useState(() => isRescidePopupHidden(staff?.email));
|
||||
|
||||
useEffect(() => {
|
||||
setPopupHidden(isRescidePopupHidden(staff?.email));
|
||||
}, [staff?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.listAppDownloads()
|
||||
.then((res) => {
|
||||
if (!cancelled) setDownloads(mergeDownloads(res.data || []));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDownloads(RESCIDE_DEFAULT_DOWNLOADS);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePopupHide = () => {
|
||||
if (!staff?.email) return;
|
||||
const next = !popupHidden;
|
||||
setRescidePopupHidden(staff.email, next);
|
||||
setPopupHidden(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>RESCIDE — IDE thi thực hành</h1>
|
||||
<p>
|
||||
Mở test thử để đánh giá và cải thiện chức năng. IDE chuyên dụng cho kỳ thi thực hành, chống gian lận
|
||||
cùng RAIA DESKTOP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tab-page-body tab-page-scroll">
|
||||
<div
|
||||
className="content-card"
|
||||
style={{
|
||||
marginBottom: '1.25rem',
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderColor: 'rgba(187, 33, 38, 0.25)',
|
||||
background: 'linear-gradient(135deg, #fff 0%, var(--accent-light) 100%)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent)', letterSpacing: '0.04em', textTransform: 'uppercase', marginBottom: '0.35rem' }}>
|
||||
Thông báo thử nghiệm
|
||||
</div>
|
||||
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.55, margin: 0, maxWidth: 640 }}>
|
||||
Vui lòng tải RESCIDE, dùng thử trong môi trường thi thực hành và góp ý để đội ngũ hoàn thiện sản phẩm.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={togglePopupHide}
|
||||
title={popupHidden ? 'Hiện popup RESCIDE mỗi khi vào web' : 'Ẩn popup RESCIDE với tài khoản này'}
|
||||
>
|
||||
{popupHidden ? 'Hiện lại popup khi vào web' : 'Ẩn popup với tài khoản này'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ marginBottom: '1.25rem', padding: '1.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: '0.75rem' }}>Chức năng tiêu biểu</h2>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: '0.95rem' }}>
|
||||
{RESCIDE_FEATURES.map((f) => (
|
||||
<li key={f} style={{ marginBottom: '0.4rem' }}>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="content-card" style={{ padding: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Tải RESCIDE</h2>
|
||||
{canManage && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('applications')}>
|
||||
Quản lý link tải (Ứng dụng)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
|
||||
Chọn bản cài đặt phù hợp hệ điều hành. Link tải được đồng bộ từ khu vực Ứng dụng
|
||||
{canManage ? ' — bạn có thể chỉnh sửa tại tab Ứng dụng.' : '.'}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{downloads.map((d) => (
|
||||
<a
|
||||
key={`${d.name}-${d.downloadUrl}`}
|
||||
href={d.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="content-card"
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-color)',
|
||||
transition: 'var(--transition)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.35)';
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignSelf: 'flex-start',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
background: 'var(--accent-light)',
|
||||
padding: '0.2rem 0.55rem',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{d.platform || 'Download'}
|
||||
</span>
|
||||
<strong style={{ fontSize: '0.95rem', color: 'var(--text-primary)' }}>{d.name}</strong>
|
||||
{d.description && (
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>{d.description}</span>
|
||||
)}
|
||||
<span style={{ marginTop: 'auto', paddingTop: '0.75rem', fontSize: '0.85rem', fontWeight: 600, color: 'var(--accent)' }}>
|
||||
Tải về →
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,8 +8,27 @@ interface StudentDetailModalProps {
|
||||
isOnline: boolean;
|
||||
sessionLog?: StudentSessionLogItem | null;
|
||||
onClose: () => void;
|
||||
/** Mở sẵn phần giám sát (từ tab Giám sát / sơ đồ) */
|
||||
initialShowProctor?: boolean;
|
||||
}
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconClose = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMonitor = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatDuration = (totalSeconds: number) => {
|
||||
const hrs = Math.floor(totalSeconds / 3600);
|
||||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||
@@ -23,24 +42,34 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
||||
isOnline,
|
||||
sessionLog,
|
||||
onClose,
|
||||
initialShowProctor = false,
|
||||
}) => {
|
||||
const [showProctor, setShowProctor] = useState(false);
|
||||
const [showProctor, setShowProctor] = useState(initialShowProctor);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay student-detail-overlay" onClick={onClose}>
|
||||
<div className={`modal-container student-detail-modal ${showProctor ? 'student-detail-modal--proctor' : ''}`} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div
|
||||
className={`modal-overlay student-detail-overlay${showProctor ? ' student-detail-overlay--proctor' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`modal-container student-detail-modal${showProctor ? ' student-detail-modal--proctor' : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header student-detail-modal-header">
|
||||
<div className="student-detail-header">
|
||||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={56} />
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>{student.fullName}</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>{student.studentCode}</span>
|
||||
{student.email && <> · {student.email}</>}
|
||||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={showProctor ? 40 : 52} />
|
||||
<div className="student-detail-title-block">
|
||||
<h2 className="modal-title student-detail-name">{student.fullName}</h2>
|
||||
<p className="student-detail-sub">
|
||||
<span className="student-detail-code">{student.studentCode}</span>
|
||||
{student.email && <span className="student-detail-email">{student.email}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
|
||||
<button type="button" className="btn btn-secondary student-detail-close" onClick={onClose}>
|
||||
<IconClose size={15} />
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="student-detail-body">
|
||||
@@ -62,20 +91,20 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
||||
<>
|
||||
<div className="student-meta-item">
|
||||
<span className="student-meta-label">Online (ca)</span>
|
||||
<span className="student-meta-value" style={{ color: 'var(--success)', fontFamily: 'monospace' }}>
|
||||
<span className="student-meta-value student-meta-value--online">
|
||||
{formatDuration(sessionLog.onlineSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="student-meta-item">
|
||||
<span className="student-meta-label">Offline (ca)</span>
|
||||
<span className="student-meta-value" style={{ color: 'var(--danger)', fontFamily: 'monospace' }}>
|
||||
<span className="student-meta-value student-meta-value--offline">
|
||||
{formatDuration(sessionLog.offlineSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
|
||||
<div className="student-meta-item">
|
||||
<span className="student-meta-label">WiFi</span>
|
||||
<span className="student-meta-value" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
<span className="student-meta-value student-meta-value--mono">
|
||||
{sessionLog.wifiSsids}
|
||||
</span>
|
||||
</div>
|
||||
@@ -86,14 +115,14 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center' }}
|
||||
onClick={() => setShowProctor(v => !v)}
|
||||
className={`btn student-detail-proctor-toggle ${showProctor ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => setShowProctor((v) => !v)}
|
||||
>
|
||||
<IconMonitor size={14} />
|
||||
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
|
||||
</button>
|
||||
{!isOnline && !showProctor && (
|
||||
<p className="schedule-hint" style={{ margin: 0 }}>
|
||||
<p className="student-detail-hint">
|
||||
Sinh viên offline — stream chỉ có khi app Simple Care đang chạy.
|
||||
</p>
|
||||
)}
|
||||
|
||||
55
management/src/components/StudentStreamImage.tsx
Normal file
55
management/src/components/StudentStreamImage.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { API_BASE } from '../api';
|
||||
|
||||
interface StudentStreamImageProps {
|
||||
studentId: number;
|
||||
kind: 'screen' | 'webcam';
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const StudentStreamImage: React.FC<StudentStreamImageProps> = ({
|
||||
studentId,
|
||||
kind,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('sc_staff_token') || '';
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
setUrl(`${API_BASE}/students/${studentId}/stream/${kind}${tokenParam}`);
|
||||
setError(false);
|
||||
}, [studentId, kind]);
|
||||
|
||||
const handleError = () => {
|
||||
setError(true);
|
||||
setTimeout(() => {
|
||||
const token = localStorage.getItem('sc_staff_token') || '';
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
setUrl(`${API_BASE}/students/${studentId}/stream/${kind}${tokenParam}&t=${Date.now()}`);
|
||||
setError(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (error || !url) {
|
||||
return (
|
||||
<div className="no-stream-placeholder">
|
||||
<p>Đang chờ {kind === 'screen' ? 'màn hình' : 'webcam'}...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={`${kind === 'screen' ? 'Màn hình' : 'Webcam'} sinh viên`}
|
||||
className={className}
|
||||
style={style}
|
||||
onError={handleError}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,90 @@ import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import type { StudentItem, SyncStatus } from '../api';
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconUsers = ({ size = 22 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="3.5" />
|
||||
<path d="M22 21v-2a3.5 3.5 0 0 0-2.5-3.35" />
|
||||
<path d="M16 3.5a3.5 3.5 0 0 1 0 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 18 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconAlert = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconSearch = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMail = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPhone = ({ size = 12 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.81.36 1.6.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c1.2.34 1.99.57 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCalendar = ({ size = 13 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M16 3v4M8 3v4M3 11h18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInbox = ({ size = 40 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronLeft = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconChevronRight = ({ size = 16 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function genderLabel(gender: number | null | undefined) {
|
||||
if (gender === 1) return 'Nam';
|
||||
if (gender === 0) return 'Nữ';
|
||||
return 'Khác';
|
||||
}
|
||||
|
||||
export const StudentsTab: React.FC = () => {
|
||||
const [students, setStudents] = useState<StudentItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -9,8 +93,6 @@ export const StudentsTab: React.FC = () => {
|
||||
const [pageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
|
||||
const fetchStudents = async () => {
|
||||
@@ -49,7 +131,6 @@ export const StudentsTab: React.FC = () => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
// Sync polling logic
|
||||
useEffect(() => {
|
||||
let timer: any;
|
||||
if (syncStatus?.running) {
|
||||
@@ -57,7 +138,7 @@ export const StudentsTab: React.FC = () => {
|
||||
const isRunning = await fetchSyncStatus();
|
||||
if (!isRunning) {
|
||||
clearInterval(timer);
|
||||
fetchStudents(); // Reload data when sync completes
|
||||
fetchStudents();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
@@ -69,77 +150,83 @@ export const StudentsTab: React.FC = () => {
|
||||
const handleStartSync = async () => {
|
||||
try {
|
||||
await api.startStudentsSync();
|
||||
// Set local state to running to trigger useEffect poller
|
||||
setSyncStatus({
|
||||
running: true,
|
||||
done: false,
|
||||
total: 0,
|
||||
synced: 0,
|
||||
updatedAt: Date.now() / 1000
|
||||
updatedAt: Date.now() / 1000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Không thể bắt đầu đồng bộ sinh viên');
|
||||
}
|
||||
};
|
||||
|
||||
// Tính toán % tiến trình sync
|
||||
const syncPercent = syncStatus && syncStatus.total > 0
|
||||
const syncPercent =
|
||||
syncStatus && syncStatus.total > 0
|
||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||
: 0;
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||
|
||||
return (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Danh Sách Sinh Viên</h1>
|
||||
<h1 className="page-title-heading">
|
||||
<span className="page-title-icon" aria-hidden>
|
||||
<IconUsers />
|
||||
</span>
|
||||
Danh sách sinh viên
|
||||
</h1>
|
||||
<p>Danh sách toàn bộ sinh viên trong hệ thống được đồng bộ</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStartSync}
|
||||
disabled={syncStatus?.running}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderRadius: '12px',
|
||||
fontWeight: 700,
|
||||
boxShadow: '0 4px 14px rgba(187,33,38,0.25)',
|
||||
}}
|
||||
>
|
||||
{syncStatus?.running ? (
|
||||
<>
|
||||
<div className="sync-spinner"></div> Đồng bộ...
|
||||
<div className="sync-spinner" style={{ width: 18, height: 18 }} />
|
||||
Đồng bộ... {syncPercent}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
|
||||
<path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
<IconRefresh />
|
||||
Đồng bộ toàn bộ SV
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sync Status Banner */}
|
||||
{syncStatus && (syncStatus.running || syncStatus.done || syncStatus.error) && (
|
||||
<div className="sync-progress-banner" style={{ borderStyle: 'solid', borderColor: 'var(--success)' }}>
|
||||
<div className="sync-progress-banner">
|
||||
<div className="sync-header">
|
||||
<div className="sync-title">
|
||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
||||
{syncStatus.running && <div className="sync-spinner" />}
|
||||
<span>
|
||||
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
|
||||
{syncStatus.done && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Đồng bộ toàn bộ sinh viên hoàn tất!
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--success)' }}>
|
||||
<IconCheck />
|
||||
Đồng bộ hoàn tất!
|
||||
</span>
|
||||
)}
|
||||
{syncStatus.error && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', color: 'var(--danger)' }}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="var(--danger)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
Đồng bộ thất bại: {syncStatus.error}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--danger)' }}>
|
||||
<IconAlert />
|
||||
{syncStatus.error}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -150,19 +237,22 @@ export const StudentsTab: React.FC = () => {
|
||||
</div>
|
||||
{syncStatus.running && (
|
||||
<div className="sync-bar-container">
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%`, background: 'linear-gradient(to right, var(--success), #a7f3d0)' }}></div>
|
||||
<div className="sync-bar" style={{ width: `${syncPercent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="sync-meta">
|
||||
<span>Cập nhật lần cuối: {new Date(syncStatus.updatedAt * 1000).toLocaleString()}</span>
|
||||
{syncStatus.running && <span style={{ color: 'var(--success)' }}>Hệ thống đang kéo dữ liệu trang {syncStatus.page}...</span>}
|
||||
{syncStatus.running && (
|
||||
<span style={{ color: 'var(--accent-hover)' }}>
|
||||
Hệ thống đang kéo dữ liệu trang {syncStatus.page}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control filters */}
|
||||
<div className="control-bar">
|
||||
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}>
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
@@ -174,142 +264,451 @@ export const StudentsTab: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
<span className="search-icon">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<IconSearch />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Students Table */}
|
||||
<div className="tab-page-body">
|
||||
<div className="table-wrapper table-fill">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }}></div>
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải danh sách sinh viên...</p>
|
||||
<div className="sync-spinner" style={{ width: 40, height: 40 }} />
|
||||
<p style={{ marginTop: '1rem', fontWeight: 600 }}>Đang tải danh sách sinh viên...</p>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon" style={{ color: 'var(--text-muted)' }}>
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<IconInbox />
|
||||
</div>
|
||||
<h2>Không có sinh viên nào</h2>
|
||||
<p>Hãy thử thay đổi bộ lọc hoặc bấm nút "Đồng bộ toàn bộ SV" để tải dữ liệu.</p>
|
||||
<p>Hãy thử thay đổi bộ lọc hoặc bấm "Đồng bộ toàn bộ SV" để tải dữ liệu.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<div className="table-scroll-container">
|
||||
<table className="data-table student-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mã SV</th>
|
||||
<th>Họ Tên</th>
|
||||
<th>Thông Tin Liên Hệ</th>
|
||||
<th>Ngày sinh / Giới tính</th>
|
||||
<th>Phân hệ</th>
|
||||
<th>Địa điểm</th>
|
||||
<th>Trạng thái</th>
|
||||
<th className="col-code">Mã SV</th>
|
||||
<th className="col-name">Họ tên</th>
|
||||
<th className="col-contact">Thông tin liên hệ</th>
|
||||
<th className="col-birth">Ngày sinh / Giới tính</th>
|
||||
<th className="col-system">Phân hệ</th>
|
||||
<th className="col-location">Địa điểm</th>
|
||||
<th className="col-status">Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map(st => (
|
||||
{students.map(st => {
|
||||
const isActive = st.status === 'Đang học' || st.status === 'active';
|
||||
return (
|
||||
<tr key={st.id}>
|
||||
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
|
||||
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
|
||||
<td className="col-code" data-label="Mã SV">
|
||||
<div className="student-code" title={st.studentCode || undefined}>
|
||||
{st.studentCode || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="col-name" data-label="Họ tên">
|
||||
<div className="student-name" title={st.fullName}>
|
||||
{st.fullName}
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
|
||||
</div>
|
||||
<div className="student-id">ID: {st.rkId}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
{st.email}
|
||||
<td className="col-contact" data-label="Thông tin liên hệ">
|
||||
<div className="meta-row" title={st.email || undefined}>
|
||||
<IconMail />
|
||||
<span className="meta-text">{st.email || '—'}</span>
|
||||
</div>
|
||||
{st.phone && (
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '2px' }}>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||
</svg>
|
||||
{st.phone}
|
||||
{st.phone ? (
|
||||
<div className="meta-row meta-row--secondary">
|
||||
<IconPhone />
|
||||
<span className="meta-text">{st.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '2px' }}>
|
||||
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
|
||||
<td className="col-birth" data-label="Ngày sinh / Giới tính">
|
||||
<div className="meta-row">
|
||||
<IconCalendar />
|
||||
<span className="meta-text">
|
||||
{st.dateOfBirth
|
||||
? new Date(st.dateOfBirth).toLocaleDateString('vi-VN')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="meta-sub">{genderLabel(st.gender)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge badge-muted">
|
||||
<td className="col-system" data-label="Phân hệ">
|
||||
<span className="badge badge-system">
|
||||
{st.systemName || 'Chung'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{st.location || '—'}</td>
|
||||
<td>
|
||||
<span className={`badge ${st.status === 'Đang học' || st.status === 'active' ? 'badge-success' : 'badge-muted'}`}>
|
||||
<td className="col-location" data-label="Địa điểm">
|
||||
<span className="location-text">{st.location || '—'}</span>
|
||||
</td>
|
||||
<td className="col-status" data-label="Trạng thái">
|
||||
<span className={`badge badge-status ${isActive ? 'is-active' : 'is-muted'}`}>
|
||||
{st.status || 'Đang học'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination controls */}
|
||||
{!loading && students.length > 0 && (
|
||||
<div className="tab-page-footer">
|
||||
<div className="pagination-row">
|
||||
<div>
|
||||
Hiển thị sinh viên thứ <b>{((page - 1) * pageSize) + 1}</b> đến <b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
|
||||
<div className="pagination-info">
|
||||
Hiển thị <b>{(page - 1) * pageSize + 1}</b> –{' '}
|
||||
<b>{Math.min(page * pageSize, total)}</b> trong tổng số <b>{total}</b> sinh viên
|
||||
</div>
|
||||
<div className="pagination-btn-group">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
aria-label="Trang trước"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
<IconChevronLeft />
|
||||
</button>
|
||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
||||
<span className="pagination-current">
|
||||
Trang {page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Trang sau"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<IconChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.table-wrapper.table-fill {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.table-scroll-container {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-title-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.page-title-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.student-table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.student-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-subtle);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding: 0.75rem 0.9rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.student-table td {
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.student-table .col-code { width: 12%; }
|
||||
.student-table .col-name { width: 18%; }
|
||||
.student-table .col-contact { width: 24%; }
|
||||
.student-table .col-birth { width: 14%; }
|
||||
.student-table .col-system { width: 12%; }
|
||||
.student-table .col-location { width: 8%; }
|
||||
.student-table .col-status { width: 12%; }
|
||||
|
||||
.student-table tbody tr {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.student-table tbody tr:hover {
|
||||
background: var(--bg-subtle) !important;
|
||||
}
|
||||
|
||||
.student-code {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.student-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.student-id {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.meta-row svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
}
|
||||
.meta-row--secondary {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.meta-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.meta-sub {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 19px;
|
||||
}
|
||||
|
||||
.badge-system {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 5.5rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.badge-status.is-active {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #2563eb;
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
.badge-status.is-muted {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.student-table .col-birth {
|
||||
display: none;
|
||||
}
|
||||
.student-table .col-code { width: 14%; }
|
||||
.student-table .col-name { width: 20%; }
|
||||
.student-table .col-contact { width: 28%; }
|
||||
.student-table .col-system { width: 14%; }
|
||||
.student-table .col-location { width: 10%; }
|
||||
.student-table .col-status { width: 14%; }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.student-table .col-system,
|
||||
.student-table .col-location {
|
||||
display: none;
|
||||
}
|
||||
.student-table .col-code { width: 18%; }
|
||||
.student-table .col-name { width: 28%; }
|
||||
.student-table .col-contact { width: 36%; }
|
||||
.student-table .col-status { width: 18%; }
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.student-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
.student-table thead {
|
||||
display: none;
|
||||
}
|
||||
.student-table tbody tr {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.student-table .col-birth,
|
||||
.student-table .col-system,
|
||||
.student-table .col-location {
|
||||
display: flex;
|
||||
}
|
||||
.student-table td {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
gap: 0.15rem;
|
||||
overflow: visible;
|
||||
width: auto !important;
|
||||
}
|
||||
.student-table td::before {
|
||||
content: attr(data-label);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.student-table td.col-status {
|
||||
grid-column: span 2;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.5rem !important;
|
||||
border-top: 1px dashed var(--border-color) !important;
|
||||
}
|
||||
.student-table td.col-status::before {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.student-code,
|
||||
.student-name,
|
||||
.meta-text,
|
||||
.badge-system {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
word-break: break-word;
|
||||
}
|
||||
.meta-sub {
|
||||
padding-left: 0;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.page-header .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.control-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search-input-wrapper {
|
||||
max-width: 100%;
|
||||
}
|
||||
.pagination-row {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.pagination-info {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.student-table tbody tr {
|
||||
padding: 0.75rem;
|
||||
gap: 0.4rem 0.75rem;
|
||||
}
|
||||
.student-code,
|
||||
.student-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.badge-status {
|
||||
min-width: 0;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.pagination-btn-group .pagination-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
apiFetchClassViolations,
|
||||
apiFetchExamViolations,
|
||||
VIOLATION_KIND_OPTIONS,
|
||||
type StudentViolationItem,
|
||||
} from '../api';
|
||||
import { kindLabel } from '../hooks/useStaffChatSocket';
|
||||
import { kindLabel, onStudentViolation } from '../hooks/useStaffChatSocket';
|
||||
|
||||
/* ─── Icon components ─────────────────────────────────────────────────────── */
|
||||
|
||||
type IconProps = { size?: number };
|
||||
|
||||
const IconAlert = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m10.29 3.86-8.19 14A2 2 0 0 0 3.82 21h16.36a2 2 0 0 0 1.72-3l-8.19-14a2 2 0 0 0-3.44 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconInfo = ({ size = 15 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRefresh = ({ size = 14 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
||||
<polyline points="21 3 21 9 15 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconShieldOff = ({ size = 36 }: IconProps) => (
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ─── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
type Props =
|
||||
| { mode: 'class'; classId: number }
|
||||
@@ -24,7 +60,17 @@ function formatTime(iso?: string): string {
|
||||
return d.toLocaleString('vi-VN');
|
||||
}
|
||||
|
||||
export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
function modeLabel(mode?: string): string {
|
||||
switch (mode) {
|
||||
case 'exam': return 'Phòng thi';
|
||||
case 'learning': return 'Lớp học';
|
||||
default: return mode || '—';
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Component ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export const ViolationsPanel = (props: Props) => {
|
||||
const [date, setDate] = useState(todayLocal);
|
||||
const [kind, setKind] = useState('');
|
||||
const [rows, setRows] = useState<StudentViolationItem[]>([]);
|
||||
@@ -48,47 +94,128 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [props, date, kind]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
return onStudentViolation((v) => {
|
||||
const matches =
|
||||
props.mode === 'class'
|
||||
? Number(v.classId) === props.classId
|
||||
: Number(v.examRoomId) === props.examId ||
|
||||
(!v.examRoomId && v.monitorMode === 'exam');
|
||||
if (!matches) return;
|
||||
if (date !== todayLocal()) return;
|
||||
void load();
|
||||
}, [load]);
|
||||
});
|
||||
}, [props, date, load]);
|
||||
|
||||
const isExam = props.mode === 'exam';
|
||||
|
||||
return (
|
||||
<div className="session-logs-panel" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', height: '100%' }}>
|
||||
<div className="attendance-toolbar" style={{ marginBottom: 0, flexWrap: 'wrap' }}>
|
||||
<label className="attendance-field">
|
||||
<div className="vp-panel">
|
||||
<style>{`
|
||||
.vp-panel { display: flex; flex-direction: column; gap: 0.65rem; height: 100%; }
|
||||
|
||||
/* Notice banner */
|
||||
.vp-notice { padding: 0.55rem 0.75rem; border-radius: var(--radius-sm); border: 1px solid; border-left-width: 3px; font-size: 0.76rem; line-height: 1.5; display: flex; align-items: flex-start; gap: 0.45rem; }
|
||||
.vp-notice--exam { background: rgba(254,243,199,0.6); border-color: #fcd34d; border-left-color: #d97706; color: #78350f; }
|
||||
.vp-notice--exam svg { color: #d97706; }
|
||||
.vp-notice--class { background: rgba(239,246,255,0.7); border-color: #bfdbfe; border-left-color: #3b82f6; color: #1e3a8a; }
|
||||
.vp-notice--class svg { color: #3b82f6; }
|
||||
.vp-notice svg { flex-shrink: 0; margin-top: 0.1rem; }
|
||||
|
||||
/* Toolbar */
|
||||
.vp-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
|
||||
.vp-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
|
||||
.vp-input { padding: 0.3rem 0.5rem; font-size: 0.78rem; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: var(--bg-card); color: var(--text-primary); }
|
||||
.vp-input:focus { outline: none; border-color: var(--accent); }
|
||||
.vp-refresh { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.28rem 0.65rem; font-size: 0.75rem; font-weight: 500; border-radius: var(--radius-sm); border: 1px solid var(--border-color); background: var(--bg-card); color: var(--text-primary); cursor: pointer; transition: background 0.15s; white-space: nowrap; }
|
||||
.vp-refresh:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
|
||||
.vp-refresh:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.vp-count { margin-left: auto; font-size: 0.77rem; color: var(--text-muted); font-weight: 600; align-self: flex-end; padding-bottom: 2px; }
|
||||
|
||||
/* Table wrapper */
|
||||
.vp-table-scroll { flex: 1; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius-sm); }
|
||||
.vp-table { width: 100%; border-collapse: collapse; }
|
||||
.vp-table thead th { padding: 0.5rem 0.8rem; background: var(--bg-subtle); font-weight: 700; font-size: 0.68rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
|
||||
.vp-table tbody tr { border-bottom: 1px solid var(--border-light); transition: background 0.1s; }
|
||||
.vp-table tbody tr:last-child { border-bottom: none; }
|
||||
.vp-table tbody tr:hover { background: var(--bg-hover); }
|
||||
.vp-table tbody tr.vp-row--close { background: rgba(239,68,68,0.04); }
|
||||
.vp-table tbody tr.vp-row--close:hover { background: rgba(239,68,68,0.08); }
|
||||
.vp-table tbody td { padding: 0.44rem 0.8rem; font-size: 0.82rem; vertical-align: middle; }
|
||||
|
||||
/* Empty state */
|
||||
.vp-empty { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem; color: var(--text-muted); font-size: 0.85rem; }
|
||||
.vp-empty svg { opacity: 0.35; }
|
||||
.vp-empty span { font-style: italic; }
|
||||
|
||||
/* Error */
|
||||
.vp-error { font-size: 0.78rem; color: var(--danger); }
|
||||
`}</style>
|
||||
|
||||
{/* ── Notice banner ── */}
|
||||
<div className={`vp-notice${isExam ? ' vp-notice--exam' : ' vp-notice--class'}`}>
|
||||
{isExam ? <IconAlert size={15} /> : <IconInfo size={15} />}
|
||||
<span>
|
||||
{isExam
|
||||
? 'Vi phạm trong phòng thi (tắt app, WiFi, môi trường…) được ghi nhận theo thời gian thực.'
|
||||
: 'Vi phạm trong giờ học (tắt app, WiFi, môi trường…) hiển thị tại đây theo từng lớp.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="vp-toolbar attendance-toolbar">
|
||||
<label className="vp-field attendance-field">
|
||||
<span>Ngày</span>
|
||||
<input type="date" className="search-input" style={{ padding: '0.5rem 0.75rem' }} value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<input
|
||||
type="date"
|
||||
className="vp-input search-input"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="attendance-field">
|
||||
<span>Loại</span>
|
||||
<select className="select-filter" value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<label className="vp-field attendance-field">
|
||||
<span>Loại vi phạm</span>
|
||||
<select
|
||||
className="vp-input select-filter"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{VIOLATION_KIND_OPTIONS.map((o) => (
|
||||
<option key={o.value || 'all'} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => void load()} disabled={loading}>
|
||||
<button
|
||||
type="button"
|
||||
className="vp-refresh btn btn-secondary btn-sm"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--text-muted)', fontWeight: 600 }}>
|
||||
{rows.length} vi phạm
|
||||
</span>
|
||||
<span className="vp-count">{rows.length} vi phạm</span>
|
||||
</div>
|
||||
|
||||
{err && <div className="form-error" style={{ margin: 0 }}>{err}</div>}
|
||||
{/* ── Error message ── */}
|
||||
{err && <div className="vp-error form-error">{err}</div>}
|
||||
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none', flex: 1, overflowY: 'auto' }}>
|
||||
{/* ── Table ── */}
|
||||
<div className="vp-table-scroll attendance-table-scroll table-wrapper">
|
||||
{loading && rows.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '220px' }}>
|
||||
<div className="sync-spinner" style={{ width: '32px', height: '32px' }} />
|
||||
<p style={{ marginTop: '0.5rem' }}>Đang tải vi phạm...</p>
|
||||
<div className="vp-empty">
|
||||
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '220px' }}>
|
||||
<p>Không có vi phạm trong ngày đã chọn.</p>
|
||||
<div className="vp-empty">
|
||||
<IconShieldOff size={36} />
|
||||
<span>Không có vi phạm trong ngày đã chọn.</span>
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table">
|
||||
<table className="vp-table data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thời gian</th>
|
||||
@@ -96,28 +223,29 @@ export const ViolationsPanel: React.FC<Props> = (props) => {
|
||||
<th>Mã SV</th>
|
||||
<th>Loại</th>
|
||||
<th>Chi tiết</th>
|
||||
<th>Chế độ</th>
|
||||
<th>Ngữ cảnh</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{rows.map((r) => {
|
||||
const isClose = r.kind === 'app_closed' || r.kind === 'unclean_shutdown';
|
||||
return (
|
||||
<tr key={r.id} className={isClose ? 'vp-row--close' : ''}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
||||
{formatTime(r.createdAt || r.clientAt)}
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{r.fullName || '—'}</td>
|
||||
<td><code>{r.studentCode || r.studentRkId}</code></td>
|
||||
<td><code style={{ fontSize: '0.78rem' }}>{r.studentCode || r.studentRkId}</code></td>
|
||||
<td>
|
||||
<span className="badge badge-warning" style={{ fontSize: '0.72rem' }}>
|
||||
<span className={`badge ${isClose ? 'badge-danger' : 'badge-warning'}`} style={{ fontSize: '0.68rem', fontWeight: 600, padding: '0.15rem 0.4rem' }}>
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ maxWidth: 360, fontSize: '0.85rem' }} title={r.reason}>
|
||||
{r.reason}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{r.monitorMode || '—'}</td>
|
||||
<td style={{ maxWidth: 320, fontSize: '0.8rem' }} title={r.reason}>{r.reason}</td>
|
||||
<td style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{modeLabel(r.monitorMode)}</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ export type StudentViolationEvent = {
|
||||
reason: string;
|
||||
monitorMode?: string;
|
||||
classId?: number;
|
||||
examRoomId?: number;
|
||||
};
|
||||
|
||||
const chatHandlers = new Set<ChatIncomingHandler>();
|
||||
@@ -41,7 +42,7 @@ function scheduleBackoff(attempt: number): number {
|
||||
|
||||
export function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'app_closed': return 'Tự đóng app';
|
||||
case 'app_closed': return 'Tắt ứng dụng';
|
||||
case 'unclean_shutdown': return 'Tắt đột ngột';
|
||||
case 'multi_monitor': return 'Nhiều màn hình';
|
||||
case 'user_switch': return 'Đổi user';
|
||||
@@ -123,6 +124,7 @@ export function StaffChatSocket() {
|
||||
reason: String(payload.data?.reason || ''),
|
||||
monitorMode: payload.data?.monitorMode || '',
|
||||
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
|
||||
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
|
||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications' | 'rescide';
|
||||
|
||||
export interface NavEntry {
|
||||
id: string;
|
||||
@@ -30,6 +30,7 @@ export const TAB_LABELS: Record<TabId, string> = {
|
||||
profile: 'Tài khoản của tôi',
|
||||
'student-affairs': 'Công Tác Sinh Viên',
|
||||
applications: 'Ứng Dụng',
|
||||
rescide: 'RESCIDE IDE',
|
||||
};
|
||||
|
||||
const HISTORY_KEY = 'sc_nav_history';
|
||||
@@ -45,7 +46,8 @@ function isTabId(value: string | null): value is TabId {
|
||||
value === 'system' ||
|
||||
value === 'profile' ||
|
||||
value === 'student-affairs' ||
|
||||
value === 'applications'
|
||||
value === 'applications' ||
|
||||
value === 'rescide'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
73
management/src/rescide.ts
Normal file
73
management/src/rescide.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/** RESCIDE IDE — thông báo & link tải (quản lý CRUD tại tab Ứng dụng, tài khoản super-admin). */
|
||||
|
||||
export const RESCIDE_SUPER_ADMIN_EMAIL = 'phuocntb@rikkeiacademy.com';
|
||||
|
||||
export const RESCIDE_POPUP_STORAGE_PREFIX = 'sc_rescide_popup_hidden:';
|
||||
|
||||
export function rescidePopupStorageKey(email: string) {
|
||||
return `${RESCIDE_POPUP_STORAGE_PREFIX}${email.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function isRescidePopupHidden(email: string | undefined | null): boolean {
|
||||
if (!email) return true;
|
||||
try {
|
||||
return localStorage.getItem(rescidePopupStorageKey(email)) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRescidePopupHidden(email: string, hidden: boolean) {
|
||||
try {
|
||||
const key = rescidePopupStorageKey(email);
|
||||
if (hidden) localStorage.setItem(key, '1');
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function isRescideManager(email: string | undefined | null): boolean {
|
||||
return email?.trim().toLowerCase() === RESCIDE_SUPER_ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
export type RescideDownload = {
|
||||
name: string;
|
||||
description: string;
|
||||
downloadUrl: string;
|
||||
platform: string;
|
||||
};
|
||||
|
||||
export const RESCIDE_DEFAULT_DOWNLOADS: RescideDownload[] = [
|
||||
{
|
||||
name: 'RESCIDE (macOS Intel)',
|
||||
description: 'IDE thi thực hành — macOS chip Intel',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg',
|
||||
platform: 'macOS',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (macOS Apple Silicon)',
|
||||
description: 'IDE thi thực hành — macOS chip Apple ARM',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg',
|
||||
platform: 'macOS',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (Windows)',
|
||||
description: 'IDE thi thực hành — Windows 10+',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip',
|
||||
platform: 'Windows',
|
||||
},
|
||||
{
|
||||
name: 'RESCIDE (Linux)',
|
||||
description: 'IDE thi thực hành — Linux x64',
|
||||
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-x64.tar.gz',
|
||||
platform: 'Linux',
|
||||
},
|
||||
];
|
||||
|
||||
export const RESCIDE_FEATURES = [
|
||||
'Kiểm soát code thời gian thực, cộng hưởng cùng RAIA DESKTOP chặn gian lận.',
|
||||
'Ngăn chặn paste code từ bên ngoài workspace.',
|
||||
'Chặn trình duyệt online và extension AI.',
|
||||
'Chặn Emmet / snippet code gian lận và nhiều chức năng ẩn khác.',
|
||||
] as const;
|
||||
@@ -48,6 +48,9 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.ClassSchedule{},
|
||||
&models.ClassCourse{},
|
||||
&models.ClassAllowedApp{},
|
||||
&models.ClassSeatingLayout{},
|
||||
&models.ExamSeatingLayout{},
|
||||
&models.StaffMyClass{},
|
||||
&models.AppPoolEntry{},
|
||||
&models.AppTemplate{},
|
||||
&models.WifiPoolEntry{},
|
||||
@@ -70,15 +73,74 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.GuideLink{},
|
||||
&models.AppDownload{},
|
||||
&models.AppGuide{},
|
||||
&models.LocalLeaveRequest{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := seedRescideAppDownloads(db); err != nil {
|
||||
log.Printf("Warning: seed RESCIDE app downloads: %v", err)
|
||||
}
|
||||
return migrateLegacyDiscoveredApps(db)
|
||||
}
|
||||
|
||||
// seedRescideAppDownloads thêm link tải RESCIDE nếu chưa có (quản lý tiếp tại tab Ứng dụng — super-admin).
|
||||
func seedRescideAppDownloads(db *gorm.DB) error {
|
||||
type seedItem struct {
|
||||
Name string
|
||||
Description string
|
||||
DownloadURL string
|
||||
Platform string
|
||||
}
|
||||
items := []seedItem{
|
||||
{
|
||||
Name: "RESCIDE (macOS Intel)",
|
||||
Description: "IDE thi thực hành — macOS chip Intel",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg",
|
||||
Platform: "macOS",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (macOS Apple Silicon)",
|
||||
Description: "IDE thi thực hành — macOS chip Apple ARM",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg",
|
||||
Platform: "macOS",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (Windows)",
|
||||
Description: "IDE thi thực hành — Windows 10+",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip",
|
||||
Platform: "Windows",
|
||||
},
|
||||
{
|
||||
Name: "RESCIDE (Linux)",
|
||||
Description: "IDE thi thực hành — Linux x64",
|
||||
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-x64.tar.gz",
|
||||
Platform: "Linux",
|
||||
},
|
||||
}
|
||||
for _, item := range items {
|
||||
var count int64
|
||||
if err := db.Model(&models.AppDownload{}).Where("download_url = ?", item.DownloadURL).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
if err := db.Create(&models.AppDownload{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
DownloadURL: item.DownloadURL,
|
||||
Platform: item.Platform,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Seeded app download: %s", item.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
|
||||
func migrateLegacyWifiTables(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable("accepted_wifis") {
|
||||
|
||||
@@ -54,6 +54,11 @@ func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
|
||||
return ExamRoomIsLive(room, now)
|
||||
}
|
||||
|
||||
// ExamRoomCanExtend — gia hạn giờ kết thúc khi phòng đang thi.
|
||||
func ExamRoomCanExtend(room models.ExamRoom, now time.Time) bool {
|
||||
return ExamRoomIsLive(room, now)
|
||||
}
|
||||
|
||||
// ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc.
|
||||
func ProcessExamRoomLifecycle(db *gorm.DB) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -565,11 +565,40 @@ func GetLeaveRequestsHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handler
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
var data any
|
||||
var data []map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
var fallback any
|
||||
if errFallback := json.Unmarshal(body, &fallback); errFallback == nil {
|
||||
return c.JSON(fallback)
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to parse QLDT response"})
|
||||
}
|
||||
|
||||
// Ghi đè trạng thái duyệt local nếu có
|
||||
var localReqs []models.LocalLeaveRequest
|
||||
if err := db.Find(&localReqs).Error; err == nil && len(localReqs) > 0 {
|
||||
localMap := make(map[int64]string)
|
||||
for _, lr := range localReqs {
|
||||
localMap[lr.LeaveID] = lr.Status
|
||||
}
|
||||
for i, item := range data {
|
||||
if idVal, ok := item["id"]; ok {
|
||||
var idInt int64
|
||||
switch v := idVal.(type) {
|
||||
case float64:
|
||||
idInt = int64(v)
|
||||
case int64:
|
||||
idInt = v
|
||||
case int:
|
||||
idInt = int64(v)
|
||||
}
|
||||
if localStatus, found := localMap[idInt]; found {
|
||||
data[i]["status"] = localStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(data)
|
||||
}
|
||||
}
|
||||
@@ -594,15 +623,24 @@ func UpdateLeaveStatusHandler(db *gorm.DB, qldtClient *qldt.Client) fiber.Handle
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Trạng thái phê duyệt không hợp lệ"})
|
||||
}
|
||||
|
||||
token := GetQldtToken(db)
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "QLDT_TOKEN chưa cấu hình"})
|
||||
// 1. Lưu trạng thái đơn phép vào database local thay vì gọi QLDT Portal API
|
||||
var localLeave models.LocalLeaveRequest
|
||||
errLocal := db.Where("leave_id = ?", leaveID).First(&localLeave).Error
|
||||
if errLocal == gorm.ErrRecordNotFound {
|
||||
localLeave = models.LocalLeaveRequest{
|
||||
LeaveID: leaveID,
|
||||
Status: req.Status,
|
||||
}
|
||||
|
||||
// 1. Gửi lệnh cập nhật status qua QLDT Portal API
|
||||
body, err := qldtClient.UpdateLeaveStatus(context.Background(), token, leaveID, req.Status)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error(), "body": string(body)})
|
||||
if err := db.Create(&localLeave).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi lưu đơn phép local: " + err.Error()})
|
||||
}
|
||||
} else if errLocal == nil {
|
||||
localLeave.Status = req.Status
|
||||
if err := db.Save(&localLeave).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Lỗi cập nhật đơn phép local: " + err.Error()})
|
||||
}
|
||||
} else {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": errLocal.Error()})
|
||||
}
|
||||
|
||||
// 2. Nếu status là "Phê duyệt", tự động cập nhật điểm danh trong hệ thống của chúng ta thành "Nghỉ có phép"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
"server/internal/middleware"
|
||||
"server/internal/models"
|
||||
internalWs "server/internal/websocket"
|
||||
|
||||
@@ -22,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
|
||||
const systemAllowedApps = "explorer.exe,taskmgr.exe,cmd.exe,powershell.exe,conhost.exe,client.exe,simple_care_v1.0.exe,simple_care.exe,wails.exe,msedgewebview2.exe,code.exe,cursor.exe,windsurf.exe,goland.exe,goland64.exe,idea64.exe,clion64.exe,webstorm64.exe,pycharm64.exe,rider64.exe,studio64.exe,eclipse.exe,sublime_text.exe,notepad++.exe,devenv.exe,git-bash.exe,bash.exe,chsime.exe,ctfmon.exe,unikey.exe,unikeyvnt.exe,gotiengviet.exe,openkey.exe,openkey64.exe,applicationframehost.exe,dwm.exe,csrss.exe,consent.exe,finder,dock,windowserver,loginwindow,systemuiserver,controlcenter,notificationcenter,spotlight,vietnamese,pboard,securityagent,keychain,coreaudiod,terminal,iterm,iterm2,zsh,sh,osascript,system events,gnome-shell,mutter,xwayland,xorg,plasmashell,hyprland,sway,waybar,rofi,gdm3,sddm,lightdm,gnome-terminal,ptyxis,konsole,fcitx5,fcitx,ibus-daemon,ibus-x11,ibus-,dbus-daemon,systemd,nm-applet,blueman-applet,kdeconnectd,shellexperiencehost.exe,startmenuexperiencehost.exe,searchhost.exe,searchapp.exe,searchindexer.exe,sihost.exe,taskhostw.exe,taskbar.exe,lockapp.exe,logonui.exe,winlogon.exe,userinit.exe,runtimebroker.exe,backgroundtaskhost.exe,wwahost.exe,microsoftedgecp.exe,textinputhost.exe,chsime.exe,imetip.exe,imecmnt.exe,googlepinyin.exe,baidu.exe,credentialuibroker.exe,smartscreen.exe,securityhealthsystray.exe,securityhealthservice.exe,wscsvc.exe,msseces.exe,msmpeng.exe,nisSrv.exe,notificationplatformcontroller,systemsettings.exe,settingssynchostservice.exe,settingssynchost.exe,regsvc.exe,spoolsv.exe,tabtip.exe,tabtip32.exe,onedrive.exe,onedriveupdater.exe,narrator.exe,magnify.exe,osk.exe,audiodg.exe,sndvol.exe,cmediaaudiocontrolpanel.exe,wuauclt.exe,musnotifyicon.exe,windowsstore.exe,winstore.app.exe,nvdisplay.container.exe,nvcontainer.exe,nvinject.exe,nvtelemetrycontainer.exe,nvvsvc.exe,nvspcaps64.exe,geforce experience.exe,radeonsoftware.exe,amddvr.exe,igfxtray.exe,igfxem.exe,igfxhk.exe,hkcmd.exe,atk hub.exe,atkex.exe,asusoptimization.exe,avastui.exe,avgui.exe,mbam.exe,mbamtray.exe,bdagent.exe,uiseagnt.exe,eguiproxy.exe,resmon.exe,perfmon.exe,mmc.exe,pwsh.exe,windowsterminal.exe,wt.exe,mintty.exe,wsl.exe,wslhost.exe,launchpad,mission control,exposé,universalaccessd,accessibilityuiagent,inputmethodkit,ibus,hiragana kakomi input,kinput2,squirrel,scim,kotoeri,pinyin - simplified,zhuyin - traditional,abc,keychainservicesagent,trustd,opendirectoryd,authorizationhost,securityd,biometricd,touchidd,audioundockhelper,audio midi setup,noiseremoval,networkd,nesessionmanager,scutil,configd,mDNSResponder,mds,mds_stores,mdworker,mdworker_shared,bird,cloudd,com.apple.icloud,cloudphotod,nsurlsessiond,system preferences,system settings,software update,app store,screensaver engine,screensharingd,com.apple.screensaver,colorsyncd,colorsync utility,nightshift,display menu,printingproxy,cupsd,crashreporter,diagnosticsd,spindump,reportmemoryexception,webkit,com.apple.webkit,com.apple.webkit.networking,applescriptkit,applescript runner,rustdesk,anydesk,teamviewer,screen sharing,wezterm,kitty,alacritty,hyper,git,git-credential-manager,git-credential-osxkeychain,github desktop,sourcetree,fork,docker,docker desktop,com.docker,mutter-x11-fram,xorg,Xorg,x11,kwin_wayland,kwin_x11,wayfire,river,labwc,openbox,i3,i3bar,awesome,bspwm,xfwm4,marco,compiz,picom,compton,wl-paste,wl-copy,wlr-randr,kanshi,wlsunset,gammastep,swaylock,swayidle,swaybg,swaync,eww,ags,wofi,fuzzel,tofi,dmenu,bemenu,gdm,lxdm,slim,greetd,gnome-session-bi,lxsession,startx,xinit,gnome-terminal-,foot,xfce4-terminal,tilix,terminator,urxvt,rxvt,sakura,st,fish,dash,ksh,fcitx-,uim,sogou-qimpanel,gcin,kimpanel,gjs,gsd-,goa-identity-ser,evolution-,gnome-keyring-d,gnome-keyring,polkit-gnome-au,polkitd,gnome-settings-d,gnome-initial-se,gnome-control-ce,gvfsd,gvfsd-,tracker-miner-,tracker3,zeitgeist,zeitgeist-,accounts-daemon,colord,power-profiles-,fprintd,fwupd,udisksd,upowerd,packagekitd,nm-dispatcher,at-spi,at-spi-bus-laun,at-spi2-registr,dbus-launch,snapd-,snapd,nm-tray,network-manager-,blueman-tray,blueman-manager,kdeconnect-indi,dunst,mako,notify-osd,xfce4-notifyd,fnott,lxqt-policykit-,xfce-polkit,mate-polkit,pkttyagent,copyq,clipit,xclip,xsel,clipman,greenclip,glycin,zenity,yad,kdialog,webkit2gtk,WebKitWebProcess,WebKitNetworkPro,rustdesk-bin,remmina,clion,webstorm,pycharm,rider,studio,simple_care_v1.0"
|
||||
const systemAllowedApps = "explorer.exe,taskmgr.exe,cmd.exe,powershell.exe,conhost.exe,client.exe,simple_care_v1.0.exe,simple_care.exe,wails.exe,msedgewebview2.exe,code.exe,cursor.exe,windsurf.exe,goland.exe,goland64.exe,idea64.exe,clion64.exe,webstorm64.exe,pycharm64.exe,rider64.exe,studio64.exe,eclipse.exe,sublime_text.exe,notepad++.exe,devenv.exe,git-bash.exe,bash.exe,chsime.exe,ctfmon.exe,unikey.exe,unikeyvnt.exe,gotiengviet.exe,openkey.exe,openkey64.exe,applicationframehost.exe,dwm.exe,csrss.exe,consent.exe,finder,dock,windowserver,loginwindow,systemuiserver,controlcenter,notificationcenter,spotlight,vietnamese,pboard,securityagent,keychain,coreaudiod,terminal,iterm,iterm2,zsh,sh,osascript,system events,gnome-shell,mutter,xwayland,xorg,plasmashell,hyprland,sway,waybar,rofi,gdm3,sddm,lightdm,gnome-terminal,ptyxis,konsole,fcitx5,fcitx,ibus-daemon,ibus-x11,ibus-,dbus-daemon,systemd,nm-applet,blueman-applet,kdeconnectd,shellexperiencehost.exe,startmenuexperiencehost.exe,searchhost.exe,searchapp.exe,searchindexer.exe,sihost.exe,taskhostw.exe,taskbar.exe,lockapp.exe,logonui.exe,winlogon.exe,userinit.exe,runtimebroker.exe,backgroundtaskhost.exe,wwahost.exe,microsoftedgecp.exe,textinputhost.exe,chsime.exe,imetip.exe,imecmnt.exe,googlepinyin.exe,baidu.exe,credentialuibroker.exe,smartscreen.exe,securityhealthsystray.exe,securityhealthservice.exe,wscsvc.exe,msseces.exe,msmpeng.exe,nisSrv.exe,notificationplatformcontroller,systemsettings.exe,settingssynchostservice.exe,settingssynchost.exe,regsvc.exe,spoolsv.exe,tabtip.exe,tabtip32.exe,onedrive.exe,onedriveupdater.exe,narrator.exe,magnify.exe,osk.exe,audiodg.exe,sndvol.exe,cmediaaudiocontrolpanel.exe,wuauclt.exe,musnotifyicon.exe,windowsstore.exe,winstore.app.exe,nvdisplay.container.exe,nvcontainer.exe,nvinject.exe,nvtelemetrycontainer.exe,nvvsvc.exe,nvspcaps64.exe,geforce experience.exe,radeonsoftware.exe,amddvr.exe,igfxtray.exe,igfxem.exe,igfxhk.exe,hkcmd.exe,atk hub.exe,atkex.exe,asusoptimization.exe,avastui.exe,avgui.exe,mbam.exe,mbamtray.exe,bdagent.exe,uiseagnt.exe,eguiproxy.exe,resmon.exe,perfmon.exe,mmc.exe,pwsh.exe,windowsterminal.exe,wt.exe,mintty.exe,wsl.exe,wslhost.exe,launchpad,mission control,exposé,universalaccessd,accessibilityuiagent,inputmethodkit,ibus,hiragana kakomi input,kinput2,squirrel,scim,kotoeri,pinyin - simplified,zhuyin - traditional,abc,keychainservicesagent,trustd,opendirectoryd,authorizationhost,securityd,biometricd,touchidd,audioundockhelper,audio midi setup,noiseremoval,networkd,nesessionmanager,scutil,configd,mDNSResponder,mds,mds_stores,mdworker,mdworker_shared,bird,cloudd,com.apple.icloud,cloudphotod,nsurlsessiond,system preferences,system settings,software update,app store,screensaver engine,screensharingd,com.apple.screensaver,colorsyncd,colorsync utility,nightshift,display menu,printingproxy,cupsd,crashreporter,diagnosticsd,spindump,reportmemoryexception,webkit,com.apple.webkit,com.apple.webkit.networking,applescriptkit,applescript runner,rustdesk,anydesk,teamviewer,screen sharing,wezterm,kitty,alacritty,hyper,git,git-credential-manager,git-credential-osxkeychain,github desktop,sourcetree,fork,docker,docker desktop,com.docker,mutter-x11-fram,xorg,Xorg,x11,kwin_wayland,kwin_x11,wayfire,river,labwc,openbox,i3,i3bar,awesome,bspwm,xfwm4,marco,compiz,picom,compton,wl-paste,wl-copy,wlr-randr,kanshi,wlsunset,gammastep,swaylock,swayidle,swaybg,swaync,eww,ags,wofi,fuzzel,tofi,dmenu,bemenu,gdm,lxdm,slim,greetd,gnome-session-bi,lxsession,startx,xinit,gnome-terminal-,foot,xfce4-terminal,tilix,terminator,urxvt,rxvt,sakura,st,fish,dash,ksh,fcitx-,uim,sogou-qimpanel,gcin,kimpanel,gjs,gsd-,goa-identity-ser,evolution-,gnome-keyring-d,gnome-keyring,polkit-gnome-au,polkitd,gnome-settings-d,gnome-initial-se,gnome-control-ce,gvfsd,gvfsd-,tracker-miner-,tracker3,zeitgeist,zeitgeist-,accounts-daemon,colord,power-profiles-,fprintd,fwupd,udisksd,upowerd,packagekitd,nm-dispatcher,at-spi,at-spi-bus-laun,at-spi2-registr,dbus-launch,snapd-,snapd,nm-tray,network-manager-,blueman-tray,blueman-manager,kdeconnect-indi,dunst,mako,notify-osd,xfce4-notifyd,fnott,lxqt-policykit-,xfce-polkit,mate-polkit,pkttyagent,copyq,clipit,xclip,xsel,clipman,greenclip,glycin,zenity,yad,kdialog,webkit2gtk,WebKitWebProcess,WebKitNetworkPro,rustdesk-bin,remmina,clion,webstorm,pycharm,rider,studio,simple_care_v1.0,egui.exe,microsoftsecurityapp.exe,msascuil.exe,pickerhost.exe,seccenter.exe,mc-web-view.exe,rsappui.exe,avpui.exe,ksdeui.exe,n360.exe,nortonsecurity.exe,sophos ui.exe,bdwtxag.exe,activity monitor,passwords,xprotectservice,xprotect,malware removal tool,mrt,avast,bitdefender,norton,sophos,malwarebytes,eset,little snitch,lulu,update-notifier,apport-gtk,kgpg,ksecretd,clamtk,clamav,seahorse,kwalletd,gufw,firewalld,pkexec"
|
||||
|
||||
|
||||
|
||||
@@ -120,8 +121,15 @@ func deliverExamPaper(db *gorm.DB, enrollmentID uint) {
|
||||
// GET /api/exam-rooms
|
||||
func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
mineOnly := c.Query("mine") == "1" || c.Query("mine") == "true"
|
||||
|
||||
q := db.Order("start_time desc")
|
||||
if mineOnly && staffID > 0 {
|
||||
q = q.Where("created_by_staff_id = ?", staffID)
|
||||
}
|
||||
var rooms []models.ExamRoom
|
||||
if err := db.Order("start_time desc").Find(&rooms).Error; err != nil {
|
||||
if err := q.Find(&rooms).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
type row struct {
|
||||
@@ -129,6 +137,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCount int `json:"studentCount"`
|
||||
PaperCount int `json:"paperCount"`
|
||||
DisplayStatus string `json:"displayStatus"`
|
||||
CreatedByStaffID uint `json:"createdByStaffId"`
|
||||
}
|
||||
out := make([]row, 0, len(rooms))
|
||||
now := time.Now()
|
||||
@@ -144,6 +153,7 @@ func ListExamRoomsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCount: int(sc),
|
||||
PaperCount: int(pc),
|
||||
DisplayStatus: internalDb.ExamDisplayStatus(r, now),
|
||||
CreatedByStaffID: r.CreatedByStaffID,
|
||||
})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": out})
|
||||
@@ -183,6 +193,7 @@ func CreateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
AllowedApps: apps,
|
||||
QuizURL: strings.TrimSpace(req.QuizURL),
|
||||
Status: models.ExamStatusDraft,
|
||||
CreatedByStaffID: middleware.StaffIDFromCtx(c),
|
||||
}
|
||||
if err := db.Create(&room).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -259,8 +270,9 @@ func GetExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
"editable": internalDb.ExamRoomEditable(room),
|
||||
"prepEditable": internalDb.ExamRoomPrepEditable(room, now),
|
||||
"canPublish": room.Status == models.ExamStatusDraft,
|
||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()),
|
||||
"canCancel": internalDb.ExamRoomCanCancel(room, time.Now()),
|
||||
"canUnpublish": internalDb.ExamRoomCanUnpublish(room, now),
|
||||
"canCancel": internalDb.ExamRoomCanCancel(room, now),
|
||||
"canExtend": internalDb.ExamRoomCanExtend(room, now),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -307,8 +319,15 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
now := time.Now()
|
||||
ds := internalDb.ExamDisplayStatus(room, now)
|
||||
if !internalDb.ExamRoomEditable(room) {
|
||||
if room.Status == models.ExamStatusReady {
|
||||
if ds == "active" {
|
||||
// Đang thi: chỉ cho sửa app được phép + giờ kết thúc (gia hạn)
|
||||
if req.Name != nil || req.StartTime != nil || req.QuizURL != nil {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Khi đang thi chỉ được sửa ứng dụng được phép hoặc gia hạn giờ kết thúc"})
|
||||
}
|
||||
} else if room.Status == models.ExamStatusReady {
|
||||
if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"})
|
||||
}
|
||||
@@ -331,6 +350,14 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"})
|
||||
}
|
||||
if ds == "active" {
|
||||
if !t.After(now) {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc mới phải sau thời điểm hiện tại"})
|
||||
}
|
||||
if !t.After(room.EndTime) {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Gia hạn phải chọn giờ kết thúc muộn hơn giờ hiện tại của phòng"})
|
||||
}
|
||||
}
|
||||
room.EndTime = t
|
||||
}
|
||||
if !room.EndTime.After(room.StartTime) {
|
||||
@@ -420,13 +447,54 @@ func CancelExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/exam-rooms/:id/extend — gia hạn thêm phút cho phòng đang thi
|
||||
func ExtendExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
id, err := parseUintParam(c, "id")
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
|
||||
}
|
||||
var req struct {
|
||||
Minutes int `json:"minutes"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
if req.Minutes < 1 || req.Minutes > 480 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Số phút gia hạn phải từ 1 đến 480"})
|
||||
}
|
||||
var room models.ExamRoom
|
||||
if err := db.First(&room, id).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||
}
|
||||
now := time.Now()
|
||||
if !internalDb.ExamRoomCanExtend(room, now) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ gia hạn được phòng thi đang diễn ra"})
|
||||
}
|
||||
base := room.EndTime
|
||||
if !base.After(now) {
|
||||
base = now
|
||||
}
|
||||
room.EndTime = base.Add(time.Duration(req.Minutes) * time.Minute)
|
||||
if err := db.Save(&room).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"room": room,
|
||||
"displayStatus": internalDb.ExamDisplayStatus(room, time.Now()),
|
||||
"addedMinutes": req.Minutes,
|
||||
"endTime": room.EndTime,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/exam-rooms/:id
|
||||
func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
email, _ := c.Locals("staffEmail").(string)
|
||||
if email != "phuocntb@rikkeiacademy.com" {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
|
||||
}
|
||||
isSuperAdmin := email == "phuocntb@rikkeiacademy.com"
|
||||
|
||||
id, err := parseUintParam(c, "id")
|
||||
if err != nil {
|
||||
@@ -436,6 +504,9 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := db.First(&room, id).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||
}
|
||||
if !isSuperAdmin && (room.CreatedByStaffID == 0 || room.CreatedByStaffID != staffID) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Chỉ người tạo phòng hoặc admin mới được xóa"})
|
||||
}
|
||||
if internalDb.ExamRoomIsLive(room, time.Now()) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"})
|
||||
}
|
||||
@@ -451,6 +522,7 @@ func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler {
|
||||
_ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error
|
||||
}
|
||||
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error
|
||||
_ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSeatingLayout{}).Error
|
||||
_ = db.Delete(&room).Error
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
@@ -593,8 +665,9 @@ func RemoveExamRoomStudentHandler(db *gorm.DB) fiber.Handler {
|
||||
if err := db.First(&room, roomID).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
|
||||
}
|
||||
if !internalDb.ExamRoomPrepEditable(room, time.Now()) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa sinh viên"})
|
||||
ds := internalDb.ExamDisplayStatus(room, time.Now())
|
||||
if ds == models.ExamStatusEnded {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã kết thúc — không thể xóa sinh viên"})
|
||||
}
|
||||
_ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
@@ -811,6 +884,43 @@ func RandomAssignExamPapersHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/exam-rooms/:id/assign-papers-batch — chia đề theo sơ đồ chỗ ngồi (frontend tính toán)
|
||||
func AssignExamPapersBatchHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
roomID, err := parseUintParam(c, "id")
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
|
||||
}
|
||||
var room models.ExamRoom
|
||||
if err := db.First(&room, roomID).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"})
|
||||
}
|
||||
if !canModifyExamPapers(db, room) {
|
||||
return c.Status(403).JSON(fiber.Map{"error": "Không thể chia gói đề khi đề đã gửi hoặc phòng thi đã kết thúc"})
|
||||
}
|
||||
var req struct {
|
||||
Assignments []struct {
|
||||
StudentRkID int64 `json:"studentRkId"`
|
||||
PaperID uint `json:"paperId"`
|
||||
} `json:"assignments"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil || len(req.Assignments) == 0 {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "assignments rỗng hoặc không hợp lệ"})
|
||||
}
|
||||
count := 0
|
||||
for _, a := range req.Assignments {
|
||||
pid := a.PaperID
|
||||
res := db.Model(&models.ExamRoomStudent{}).
|
||||
Where("exam_room_id = ? AND student_rk_id = ?", roomID, a.StudentRkID).
|
||||
Update("assigned_paper_id", pid)
|
||||
if res.RowsAffected > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return c.JSON(fiber.Map{"assigned": count})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/exam-rooms/:id/send-papers
|
||||
func SendExamPapersHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -744,6 +744,8 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"` // RFC3339 optional
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
@@ -752,12 +754,30 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and kind are required"})
|
||||
}
|
||||
|
||||
monitorMode := strings.TrimSpace(req.MonitorMode)
|
||||
// Chỉ lưu khi đang thi hoặc đang học — ngoài giờ / chưa cấu hình bỏ qua
|
||||
if monitorMode != "exam" && monitorMode != "learning" {
|
||||
return c.JSON(fiber.Map{"ok": true, "skipped": true, "reason": "not_in_exam_or_learning"})
|
||||
}
|
||||
|
||||
kind := strings.ToLower(strings.TrimSpace(req.Kind))
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = kind
|
||||
}
|
||||
classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
|
||||
classID := req.ClassRkID
|
||||
if classID <= 0 {
|
||||
classID = internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
}
|
||||
|
||||
examRoomID := req.ExamRoomID
|
||||
if examRoomID == 0 && monitorMode == "exam" {
|
||||
if examInfo := internalDb.FindActiveExamForStudent(db, req.StudentRkID); examInfo != nil {
|
||||
examRoomID = examInfo.Room.ID
|
||||
}
|
||||
}
|
||||
|
||||
clientAt := time.Now()
|
||||
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
|
||||
clientAt = t
|
||||
@@ -766,9 +786,10 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
row := models.StudentViolation{
|
||||
StudentRkID: req.StudentRkID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
Kind: kind,
|
||||
Reason: reason,
|
||||
MonitorMode: strings.TrimSpace(req.MonitorMode),
|
||||
MonitorMode: monitorMode,
|
||||
ClientAt: clientAt,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
@@ -796,6 +817,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"studentName": studentName,
|
||||
"studentCode": studentCode,
|
||||
"classId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"kind": kind,
|
||||
"reason": reason,
|
||||
"monitorMode": row.MonitorMode,
|
||||
@@ -803,7 +825,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"createdAt": row.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "id": row.ID})
|
||||
return c.JSON(fiber.Map{"ok": true, "id": row.ID, "classRkId": classID, "examRoomId": examRoomID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +835,7 @@ type studentViolationItem struct {
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
@@ -838,7 +861,7 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
return c.JSON(fiber.Map{"data": []any{}, "date": date})
|
||||
}
|
||||
studentIDs := make([]int64, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
@@ -860,7 +883,11 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||
// Ưu tiên vi phạm gắn đúng lớp; fallback bản ghi cũ (class_rk_id=0) của SV trong lớp khi đang học
|
||||
q := db.Where(
|
||||
"created_at >= ? AND created_at < ? AND ((class_rk_id = ?) OR (class_rk_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
|
||||
dayStart, dayEnd, classRkID, "learning", studentIDs,
|
||||
)
|
||||
if kindFilter != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -872,12 +899,16 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
out := make([]studentViolationItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
st := nameByID[r.StudentRkID]
|
||||
if st.RkID == 0 {
|
||||
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
|
||||
}
|
||||
out = append(out, studentViolationItem{
|
||||
ID: r.ID,
|
||||
StudentRkID: r.StudentRkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
@@ -907,7 +938,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(roster) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
return c.JSON(fiber.Map{"data": []any{}, "date": date})
|
||||
}
|
||||
studentIDs := make([]int64, 0, len(roster))
|
||||
for _, s := range roster {
|
||||
@@ -927,7 +958,11 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||
// Ưu tiên exam_room_id; fallback bản ghi cũ (exam_room_id=0, mode=exam) của SV trong phòng
|
||||
q := db.Where(
|
||||
"created_at >= ? AND created_at < ? AND ((exam_room_id = ?) OR (exam_room_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
|
||||
dayStart, dayEnd, id, "exam", studentIDs,
|
||||
)
|
||||
if kindFilter != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -945,6 +980,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
|
||||
184
server/internal/handlers/handlers_seating.go
Normal file
184
server/internal/handlers/handlers_seating.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"server/internal/middleware"
|
||||
"server/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// GET /classes/:rkId/seating-layout
|
||||
func GetClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
|
||||
}
|
||||
var layout models.ClassSeatingLayout
|
||||
if err := db.Where("class_rk_id = ?", classRkID).First(&layout).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.JSON(fiber.Map{"layoutJson": nil})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"layoutJson": layout.LayoutJSON})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /classes/:rkId/seating-layout
|
||||
func PutClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
|
||||
}
|
||||
var body struct {
|
||||
LayoutJSON string `json:"layoutJson"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||
}
|
||||
if body.LayoutJSON == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "layoutJson is required"})
|
||||
}
|
||||
layout := models.ClassSeatingLayout{
|
||||
ClassRkID: classRkID,
|
||||
LayoutJSON: body.LayoutJSON,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
result := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "class_rk_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"layout_json", "updated_at"}),
|
||||
}).Create(&layout)
|
||||
if result.Error != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /classes/:rkId/seating-layout
|
||||
func DeleteClassSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
|
||||
}
|
||||
db.Where("class_rk_id = ?", classRkID).Delete(&models.ClassSeatingLayout{})
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /exam-rooms/:id/seating-layout
|
||||
func GetExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
examID, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"})
|
||||
}
|
||||
var layout models.ExamSeatingLayout
|
||||
if err := db.Where("exam_room_id = ?", examID).First(&layout).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.JSON(fiber.Map{"layoutJson": nil})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"layoutJson": layout.LayoutJSON})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /exam-rooms/:id/seating-layout
|
||||
func PutExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
examID, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"})
|
||||
}
|
||||
var body struct {
|
||||
LayoutJSON string `json:"layoutJson"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil || body.LayoutJSON == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "layoutJson is required"})
|
||||
}
|
||||
layout := models.ExamSeatingLayout{
|
||||
ExamRoomID: uint(examID),
|
||||
LayoutJSON: body.LayoutJSON,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
result := db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "exam_room_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"layout_json", "updated_at"}),
|
||||
}).Create(&layout)
|
||||
if result.Error != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /exam-rooms/:id/seating-layout
|
||||
func DeleteExamSeatingLayoutHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
examID, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room ID"})
|
||||
}
|
||||
db.Where("exam_room_id = ?", examID).Delete(&models.ExamSeatingLayout{})
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /staff/my-classes
|
||||
func GetMyClassesHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
type classRow struct {
|
||||
RkID int64 `json:"rkId"`
|
||||
Name string `json:"name"`
|
||||
ClassCode string `json:"classCode"`
|
||||
}
|
||||
var rows []classRow
|
||||
db.Table("staff_my_classes").
|
||||
Select("classes.rk_id, classes.name, classes.class_code").
|
||||
Joins("JOIN classes ON classes.rk_id = staff_my_classes.class_rk_id").
|
||||
Where("staff_my_classes.staff_id = ?", staffID).
|
||||
Scan(&rows)
|
||||
if rows == nil {
|
||||
rows = []classRow{}
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": rows})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /staff/my-classes/:classRkId
|
||||
func AddMyClassHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
classRkID, err := strconv.ParseInt(c.Params("classRkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
|
||||
}
|
||||
rec := models.StaffMyClass{StaffID: staffID, ClassRkID: classRkID}
|
||||
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rec)
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /staff/my-classes/:classRkId
|
||||
func RemoveMyClassHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
staffID := middleware.StaffIDFromCtx(c)
|
||||
classRkID, err := strconv.ParseInt(c.Params("classRkId"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class ID"})
|
||||
}
|
||||
db.Where("staff_id = ? AND class_rk_id = ?", staffID, classRkID).Delete(&models.StaffMyClass{})
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
func RequireStaff() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
header := c.Get("Authorization")
|
||||
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||
var token string
|
||||
if header != "" && strings.HasPrefix(header, "Bearer ") {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
} else {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
claims, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||
|
||||
@@ -23,6 +23,7 @@ type ExamRoom struct {
|
||||
GitBranch string `gorm:"column:git_branch;size:64;default:main" json:"gitBranch"`
|
||||
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
|
||||
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
|
||||
CreatedByStaffID uint `gorm:"column:created_by_staff_id;index;default:0" json:"createdByStaffId"`
|
||||
}
|
||||
|
||||
func (ExamRoom) TableName() string { return "exam_rooms" }
|
||||
|
||||
@@ -97,6 +97,24 @@ type AttendanceResult struct {
|
||||
|
||||
func (AttendanceResult) TableName() string { return "attendance_results" }
|
||||
|
||||
// ClassSeatingLayout lưu sơ đồ chỗ ngồi theo lớp
|
||||
type ClassSeatingLayout struct {
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
||||
LayoutJSON string `gorm:"column:layout_json;type:longtext;not null" json:"layoutJson"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ClassSeatingLayout) TableName() string { return "class_seating_layouts" }
|
||||
|
||||
// ExamSeatingLayout lưu sơ đồ chỗ ngồi theo phòng thi
|
||||
type ExamSeatingLayout struct {
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;primaryKey;not null" json:"examRoomId"`
|
||||
LayoutJSON string `gorm:"column:layout_json;type:longtext;not null" json:"layoutJson"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ExamSeatingLayout) TableName() string { return "exam_seating_layouts" }
|
||||
|
||||
// ClassAllowedApp lưu từ khóa app được mở của lớp
|
||||
type ClassAllowedApp struct {
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
||||
@@ -185,6 +203,7 @@ type StudentViolation struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;index;default:0" json:"examRoomId"`
|
||||
Kind string `gorm:"column:kind;size:64;not null;index" json:"kind"` // app_closed | unclean_shutdown | multi_monitor | user_switch | session_change | virtual_desktop | wifi | guard
|
||||
Reason string `gorm:"column:reason;type:text" json:"reason"`
|
||||
MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"`
|
||||
@@ -237,4 +256,16 @@ type AppGuide struct {
|
||||
|
||||
func (AppGuide) TableName() string { return "app_guides" }
|
||||
|
||||
// LocalLeaveRequest lưu trạng thái phê duyệt đơn xin nghỉ phép trên hệ thống nội bộ thay vì call QLĐT
|
||||
type LocalLeaveRequest struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LeaveID int64 `gorm:"column:leave_id;uniqueIndex;not null" json:"leaveId"`
|
||||
Status string `gorm:"column:status;size:32;not null" json:"status"` // "Phê duyệt" hoặc "Từ chối"
|
||||
}
|
||||
|
||||
func (LocalLeaveRequest) TableName() string { return "local_leave_requests" }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ type PasswordResetToken struct {
|
||||
|
||||
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
|
||||
|
||||
// StaffMyClass lưu danh sách lớp học cá nhân của giáo viên
|
||||
type StaffMyClass struct {
|
||||
StaffID uint `gorm:"column:staff_id;primaryKey;not null;index" json:"staffId"`
|
||||
ClassRkID int64 `gorm:"column:class_rk_id;primaryKey;not null" json:"classRkId"`
|
||||
}
|
||||
|
||||
func (StaffMyClass) TableName() string { return "staff_my_classes" }
|
||||
|
||||
// ChatMessage — tin nhắn thầy cô ↔ sinh viên
|
||||
type ChatMessage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"gorm.io/gorm"
|
||||
internalDb "server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -60,6 +66,10 @@ type WsHub struct {
|
||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
||||
grace map[int64]offlineGrace
|
||||
graceTimers map[int64]*time.Timer
|
||||
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
|
||||
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
|
||||
httpScreenSubscribers map[int64][]chan []byte // studentId -> list of channels for screen MJPEG
|
||||
httpWebcamSubscribers map[int64][]chan []byte // studentId -> list of channels for webcam MJPEG
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
@@ -69,6 +79,10 @@ var Hub = &WsHub{
|
||||
subscribers: make(map[int64][]string),
|
||||
grace: make(map[int64]offlineGrace),
|
||||
graceTimers: make(map[int64]*time.Timer),
|
||||
subscriberModes: make(map[string]string),
|
||||
lastRelayed: make(map[string]time.Time),
|
||||
httpScreenSubscribers: make(map[int64][]chan []byte),
|
||||
httpWebcamSubscribers: make(map[int64][]chan []byte),
|
||||
}
|
||||
|
||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
@@ -317,6 +331,12 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
newList = append(newList, addr)
|
||||
}
|
||||
}
|
||||
// Clean up mode map for this student and teacher
|
||||
key := c.Addr + "_" + strconv.FormatInt(sID, 10)
|
||||
delete(h.subscriberModes, key)
|
||||
delete(h.lastRelayed, key+"_screenshot_stream_frame")
|
||||
delete(h.lastRelayed, key+"_webcam_stream_frame")
|
||||
|
||||
if len(newList) == 0 {
|
||||
delete(h.subscribers, sID)
|
||||
if student, exists := h.students[sID]; exists {
|
||||
@@ -330,7 +350,7 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
||||
func (h *WsHub) Subscribe(teacherAddr string, studentID int64, mode string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -344,9 +364,12 @@ func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
||||
}
|
||||
if !alreadySubscribed {
|
||||
h.subscribers[studentID] = append(teachersList, teacherAddr)
|
||||
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
|
||||
log.Printf("[WS] Teacher %s subscribed to student %d stream in %s mode", teacherAddr, studentID, mode)
|
||||
}
|
||||
|
||||
key := teacherAddr + "_" + strconv.FormatInt(studentID, 10)
|
||||
h.subscriberModes[key] = mode
|
||||
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
@@ -357,6 +380,11 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
key := teacherAddr + "_" + strconv.FormatInt(studentID, 10)
|
||||
delete(h.subscriberModes, key)
|
||||
delete(h.lastRelayed, key+"_screenshot_stream_frame")
|
||||
delete(h.lastRelayed, key+"_webcam_stream_frame")
|
||||
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
if !exists {
|
||||
return
|
||||
@@ -381,55 +409,105 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||
h.mu.RLock()
|
||||
func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json.RawMessage) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Broadcast to HTTP subscribers if any
|
||||
if event == "screenshot_stream_frame" {
|
||||
subs, exists := h.httpScreenSubscribers[studentID]
|
||||
if exists && len(subs) > 0 {
|
||||
var b64Str string
|
||||
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
|
||||
if idx := strings.Index(b64Str, ","); idx != -1 {
|
||||
b64Str = b64Str[idx+1:]
|
||||
}
|
||||
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- rawBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if event == "webcam_stream_frame" {
|
||||
subs, exists := h.httpWebcamSubscribers[studentID]
|
||||
if exists && len(subs) > 0 {
|
||||
var b64Str string
|
||||
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
|
||||
if idx := strings.Index(b64Str, ","); idx != -1 {
|
||||
b64Str = b64Str[idx+1:]
|
||||
}
|
||||
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- rawBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
if !exists || len(teachersList) == 0 {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
addrs := make([]string, len(teachersList))
|
||||
copy(addrs, teachersList)
|
||||
h.mu.RUnlock()
|
||||
|
||||
relayEvent := "teacher:screenshot-stream-frame"
|
||||
if event == "webcam_stream_frame" {
|
||||
relayEvent = "teacher:webcam-stream-frame"
|
||||
}
|
||||
|
||||
msg := SocketMsg{
|
||||
Event: relayEvent,
|
||||
Data: map[string]any{
|
||||
"studentId": studentID,
|
||||
"imageBuffer": data["imageBuffer"],
|
||||
},
|
||||
type RelayFrameRawMsg struct {
|
||||
Event string `json:"event"`
|
||||
Data struct {
|
||||
StudentID int64 `json:"studentId"`
|
||||
ImageBuffer json.RawMessage `json:"imageBuffer"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
msg := RelayFrameRawMsg{
|
||||
Event: relayEvent,
|
||||
}
|
||||
msg.Data.StudentID = studentID
|
||||
msg.Data.ImageBuffer = rawImageBuffer
|
||||
|
||||
msgBytes, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var dead []string
|
||||
h.mu.RLock()
|
||||
for _, addr := range addrs {
|
||||
if t, found := h.teachers[addr]; found {
|
||||
if err := t.WriteRaw(msgBytes); err != nil {
|
||||
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
|
||||
dead = append(dead, addr)
|
||||
now := time.Now()
|
||||
for _, addr := range teachersList {
|
||||
t, found := h.teachers[addr]
|
||||
if !found || t == nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
for _, addr := range dead {
|
||||
if t, ok := func() (*SocketClient, bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
t, ok := h.teachers[addr]
|
||||
return t, ok
|
||||
}(); ok && t != nil {
|
||||
_ = t.Conn.Close()
|
||||
subKey := addr + "_" + strconv.FormatInt(studentID, 10)
|
||||
mode := h.subscriberModes[subKey]
|
||||
|
||||
if mode == "grid" {
|
||||
// In grid mode, rate limit to max 1 frame per 3 seconds per stream type
|
||||
relayKey := subKey + "_" + event
|
||||
lastTime, ok := h.lastRelayed[relayKey]
|
||||
if ok && now.Sub(lastTime) < 3*time.Second {
|
||||
continue
|
||||
}
|
||||
h.lastRelayed[relayKey] = now
|
||||
}
|
||||
|
||||
// Send asynchronously to avoid head-of-line blocking on slower clients
|
||||
go func(client *SocketClient, data []byte) {
|
||||
if err := client.WriteRaw(data); err != nil {
|
||||
log.Printf("[WS] Relay to teacher %s failed: %v", client.Addr, err)
|
||||
_ = client.Conn.Close()
|
||||
}
|
||||
}(t, msgBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,6 +575,29 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
}
|
||||
_ = c.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
|
||||
type EventOnlyMsg struct {
|
||||
Event string `json:"event"`
|
||||
}
|
||||
var eventMsg EventOnlyMsg
|
||||
if err := json.Unmarshal(msgBytes, &eventMsg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if eventMsg.Event == "screenshot_stream_frame" || eventMsg.Event == "webcam_stream_frame" {
|
||||
var frameMsg struct {
|
||||
Data struct {
|
||||
ImageBuffer json.RawMessage `json:"imageBuffer"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(msgBytes, &frameMsg); err == nil && len(frameMsg.Data.ImageBuffer) > 0 {
|
||||
// Copy slice to avoid memory race / corruption since WebSocket read buffer gets recycled
|
||||
bufCopy := make([]byte, len(frameMsg.Data.ImageBuffer))
|
||||
copy(bufCopy, frameMsg.Data.ImageBuffer)
|
||||
Hub.RelayFrameRaw(client.StudentID, eventMsg.Event, json.RawMessage(bufCopy))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
var msg SocketMsg
|
||||
if err := json.Unmarshal(msgBytes, &msg); err != nil {
|
||||
continue
|
||||
@@ -506,9 +607,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
case "client:ping":
|
||||
_ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}})
|
||||
|
||||
case "screenshot_stream_frame", "webcam_stream_frame":
|
||||
Hub.RelayFrame(client.StudentID, msg.Event, msg.Data)
|
||||
|
||||
case "teacher:subscribe":
|
||||
if client.Role == "teacher" {
|
||||
if sIDVal, ok := msg.Data["studentId"]; ok {
|
||||
@@ -519,8 +617,15 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
case string:
|
||||
sID, _ = strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
|
||||
// Nhận chế độ subscription (mặc định là "focus")
|
||||
mode := "focus"
|
||||
if mVal, ok := msg.Data["mode"].(string); ok && mVal != "" {
|
||||
mode = mVal
|
||||
}
|
||||
|
||||
if sID > 0 {
|
||||
Hub.Subscribe(client.Addr, sID)
|
||||
Hub.Subscribe(client.Addr, sID, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,3 +649,134 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) RegisterHttpSubscriber(studentID int64, kind string) chan []byte {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
ch := make(chan []byte, 16)
|
||||
if kind == "screen" {
|
||||
h.httpScreenSubscribers[studentID] = append(h.httpScreenSubscribers[studentID], ch)
|
||||
} else if kind == "webcam" {
|
||||
h.httpWebcamSubscribers[studentID] = append(h.httpWebcamSubscribers[studentID], ch)
|
||||
}
|
||||
|
||||
// Always trigger streams start if there's any HTTP subscriber
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *WsHub) UnregisterHttpSubscriber(studentID int64, kind string, ch chan []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if kind == "screen" {
|
||||
subs := h.httpScreenSubscribers[studentID]
|
||||
var next []chan []byte
|
||||
for _, c := range subs {
|
||||
if c != ch {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.httpScreenSubscribers, studentID)
|
||||
} else {
|
||||
h.httpScreenSubscribers[studentID] = next
|
||||
}
|
||||
} else if kind == "webcam" {
|
||||
subs := h.httpWebcamSubscribers[studentID]
|
||||
var next []chan []byte
|
||||
for _, c := range subs {
|
||||
if c != ch {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.httpWebcamSubscribers, studentID)
|
||||
} else {
|
||||
h.httpWebcamSubscribers[studentID] = next
|
||||
}
|
||||
}
|
||||
|
||||
// If no subscribers left (WS or HTTP), stop student's stream
|
||||
wsSubs := h.subscribers[studentID]
|
||||
httpScSubs := h.httpScreenSubscribers[studentID]
|
||||
httpCamSubs := h.httpWebcamSubscribers[studentID]
|
||||
|
||||
if len(wsSubs) == 0 && len(httpScSubs) == 0 && len(httpCamSubs) == 0 {
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||
}
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func GetStudentScreenStreamHandler(c *fiber.Ctx) error {
|
||||
studentIDVal := c.Params("studentId")
|
||||
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
|
||||
}
|
||||
|
||||
ch := Hub.RegisterHttpSubscriber(studentID, "screen")
|
||||
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Pragma", "no-cache")
|
||||
|
||||
c.Status(fiber.StatusOK)
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
defer Hub.UnregisterHttpSubscriber(studentID, "screen", ch)
|
||||
|
||||
for frame := range ch {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
|
||||
_, _ = w.Write(frame)
|
||||
_, _ = fmt.Fprintf(w, "\r\n")
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetStudentWebcamStreamHandler(c *fiber.Ctx) error {
|
||||
studentIDVal := c.Params("studentId")
|
||||
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
|
||||
}
|
||||
|
||||
ch := Hub.RegisterHttpSubscriber(studentID, "webcam")
|
||||
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Pragma", "no-cache")
|
||||
|
||||
c.Status(fiber.StatusOK)
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
defer Hub.UnregisterHttpSubscriber(studentID, "webcam", ch)
|
||||
|
||||
for frame := range ch {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
|
||||
_, _ = w.Write(frame)
|
||||
_, _ = fmt.Fprintf(w, "\r\n")
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -130,9 +130,19 @@ func main() {
|
||||
staff.Get("/classes/:rkId", handlers.GetClassHandler(gormDB))
|
||||
staff.Patch("/classes/:rkId/studying", handlers.PatchClassStudyingHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/students", handlers.ListClassStudentsHandler(gormDB))
|
||||
staff.Get("/classes/:rkId/seating-layout", handlers.GetClassSeatingLayoutHandler(gormDB))
|
||||
staff.Put("/classes/:rkId/seating-layout", handlers.PutClassSeatingLayoutHandler(gormDB))
|
||||
staff.Delete("/classes/:rkId/seating-layout", handlers.DeleteClassSeatingLayoutHandler(gormDB))
|
||||
|
||||
// My classes (lớp cá nhân theo tài khoản)
|
||||
staff.Get("/staff/my-classes", handlers.GetMyClassesHandler(gormDB))
|
||||
staff.Post("/staff/my-classes/:classRkId", handlers.AddMyClassHandler(gormDB))
|
||||
staff.Delete("/staff/my-classes/:classRkId", handlers.RemoveMyClassHandler(gormDB))
|
||||
|
||||
// Students endpoints
|
||||
staff.Get("/students", handlers.ListAllStudentsHandler(gormDB))
|
||||
staff.Get("/students/:studentId/stream/screen", internalWs.GetStudentScreenStreamHandler)
|
||||
staff.Get("/students/:studentId/stream/webcam", internalWs.GetStudentWebcamStreamHandler)
|
||||
|
||||
// Sync endpoints
|
||||
staff.Post("/sync/classes/start", handlers.StartClassesSyncHandler(gormDB, qldtClient, classesJob))
|
||||
@@ -203,7 +213,12 @@ func main() {
|
||||
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/extend", handlers.ExtendExamRoomHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/assign-papers-batch", handlers.AssignExamPapersBatchHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/seating-layout", handlers.GetExamSeatingLayoutHandler(gormDB))
|
||||
staff.Put("/exam-rooms/:id/seating-layout", handlers.PutExamSeatingLayoutHandler(gormDB))
|
||||
staff.Delete("/exam-rooms/:id/seating-layout", handlers.DeleteExamSeatingLayoutHandler(gormDB))
|
||||
staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
|
||||
staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB))
|
||||
|
||||
9716
simple_care.sql
9716
simple_care.sql
File diff suppressed because it is too large
Load Diff
73
walkthrough.md
Normal file
73
walkthrough.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Walkthrough - Attendance Local Approval, Modal Redesign & Native HTTP MJPEG Streaming
|
||||
|
||||
We have successfully updated the leave requests approval logic to operate locally, redesigned the attendance panel into a spacious full-height modal, and implemented a high-performance native HTTP MJPEG streaming architecture based on `raia_v3`.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Backend Implementation
|
||||
|
||||
#### Leave Approvals & Redesign
|
||||
- **Model Addition** ([models.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/models/models.go)):
|
||||
- Defined `LocalLeaveRequest` to keep track of approvals and rejections locally inside Simple Care database.
|
||||
- **Database Migration** ([db.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/db/db.go)):
|
||||
- Registered `LocalLeaveRequest` model for Gorm AutoMigrate.
|
||||
- **Approval Logic** ([handlers_attendance.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/handlers/handlers_attendance.go)):
|
||||
- Modified `UpdateLeaveStatusHandler` to store status overrides directly to the local database, completely bypassing the external `qldtClient.UpdateLeaveStatus` API call.
|
||||
- **Leave Query status overriding** ([handlers_attendance.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/handlers/handlers_attendance.go)):
|
||||
- Modified `GetLeaveRequestsHandler` to intercept the QLDT API list response and override the status field of leave requests with the stored local database values before returning it to the frontend.
|
||||
|
||||
#### High-Performance HTTP MJPEG Streaming
|
||||
- **HTTP MJPEG Endpoint Support** ([websocket.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/websocket/websocket.go)):
|
||||
- Added `RegisterHttpSubscriber` and `UnregisterHttpSubscriber` methods to `WsHub` to manage active HTTP streaming connections dynamically.
|
||||
- Implemented `GetStudentScreenStreamHandler` and `GetStudentWebcamStreamHandler` Fiber controllers.
|
||||
- Upon receiving streaming frames over the WebSocket connection from a student, the server decodes the base64 JPEGs to raw binary JPEGs and feeds them directly to the corresponding HTTP MJPEG channels.
|
||||
- Automatically triggers the student's screenshot/webcam capture stream when an HTTP connection is established, and stops it when all subscribers disconnect, saving huge amounts of client CPU and network bandwidth.
|
||||
- **Unified Query Parameter Auth** ([staff.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/internal/middleware/staff.go)):
|
||||
- Updated `RequireStaff()` middleware to accept the JWT token from the query parameter `?token=...` if no `Authorization` header is present. This allows standard `<img>` tags to securely request stream data.
|
||||
- **Stream Routing** ([main.go](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/server/main.go)):
|
||||
- Registered routes `/api/students/:studentId/stream/screen` and `/api/students/:studentId/stream/webcam` under the authenticated `staff` router.
|
||||
|
||||
---
|
||||
|
||||
### 2. Frontend UI / UX Redesign
|
||||
|
||||
#### Modal wrapper for Attendance
|
||||
- **Modal layout** ([AttendancePanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/AttendancePanel.tsx)):
|
||||
- Redesigned the main container of `<AttendancePanel>` to be a full-screen backdrop modal (`.attendance-modal-overlay` and `.attendance-modal-container`) with a top header containing a close button (`×`).
|
||||
- **Modal Dismiss callback** ([ClassWorkspace.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ClassWorkspace.tsx)):
|
||||
- Configured `onClose` callback on the `<AttendancePanel>` to automatically navigate the teacher back to the roster tab ('roster') when they dismiss the modal.
|
||||
- **Leave Modal Notices** ([AttendancePanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/AttendancePanel.tsx)):
|
||||
- Added a clear alert notice informing teachers that approving or rejecting a leave request records the action locally inside Simple Care only and does not sync back to the QLDT portal. Approving will mark the student's status to "Nghỉ có phép".
|
||||
- **Responsive CSS Styles** ([index.css](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/index.css)):
|
||||
- Implemented `.attendance-modal-overlay`, `.attendance-modal-container`, and `.attendance-modal-body` CSS styles. The container utilizes `width: 98vw !important` and `height: 96vh !important` to provide a spacious UI layout with full scroll support, avoiding squeezed tables on small screens and low-height devices.
|
||||
- **Exam Violations Notice** ([ViolationsPanel.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ViolationsPanel.tsx)):
|
||||
- Added an amber warning notice label in the violations list tab specifically during exam mode (`props.mode === 'exam'`) stating: *"Lưu ý: Chức năng theo dõi vi phạm đang được theo dõi đánh giá tính chuẩn xác, hiện tại kết quả chạy thử chỉ mang tính chất tham khảo."*
|
||||
|
||||
#### Native Stream Rendering (MJPEG)
|
||||
- **StudentStreamImage Component** ([StudentStreamImage.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/StudentStreamImage.tsx)):
|
||||
- Created a reusable component that binds directly to the HTTP stream.
|
||||
- Automatically manages connection establishment, listens to error states (e.g. when a student is offline or stream is dropped), and retries the connection after a small delay.
|
||||
- **Stream Panel Integration** ([ProctorStreamPanels.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ProctorStreamPanels.tsx)):
|
||||
- Rewrote the panel component to render screens and webcams using `<StudentStreamImage>`.
|
||||
- Completely removed all complex WebSocket subscription state management, subscriptions, and connection setups from the component, eliminating lag, thread blocks, and frontend JSON parsing overhead.
|
||||
- **Grid View Integration** ([ExamGridProctor.tsx](file:///c:/Users/PhuocNTB/Desktop/simple_care_project/management/src/components/ExamGridProctor.tsx)):
|
||||
- Simplified the grid view by using `<StudentStreamImage>` for both card frames and the zoomed modal.
|
||||
- Bypassed WebSocket connections completely for grid monitoring, reducing frontend CPU load and rendering overhead to zero.
|
||||
|
||||
---
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Backend Compile Check
|
||||
- Built Go code successfully:
|
||||
```powershell
|
||||
go build -o server_test.exe main.go
|
||||
```
|
||||
Backend compiles cleanly without errors.
|
||||
|
||||
### Frontend Build Check
|
||||
- Ran Vite production compilation:
|
||||
```powershell
|
||||
npx tsc --noEmit
|
||||
```
|
||||
Frontend builds and type-checks successfully with all TS checks passing.
|
||||
Reference in New Issue
Block a user