Compare commits
105 Commits
0abf48ea2d
...
Hungdev
| Author | SHA1 | Date | |
|---|---|---|---|
| c41f465d8a | |||
| 2bdec7d7e7 | |||
| 232f9873a0 | |||
| 6ee9773f7f | |||
| aed692cffe | |||
| 1ed246eec8 | |||
| a55b0a4567 | |||
| f4b9d99051 | |||
| 487e9eb8ea | |||
| 7d3fdefa3f | |||
| 0ff025fb15 | |||
| b56c6d722c | |||
| 83821b6b82 | |||
| 31943bf489 | |||
| 24a4ef2c66 | |||
| 120ff1f3c8 | |||
| 84ad60841c | |||
| bad51585db | |||
| 06f2edb195 | |||
| 0583c7869a | |||
| b4c3155564 | |||
| 58c96609b6 | |||
| 8803cd622a | |||
| 719704f19e | |||
| 2bb856f2fd | |||
| 939097ddd9 | |||
| 6cc520a0c4 | |||
| 6502531ad7 | |||
| f5dce38e11 | |||
| 573ff17581 | |||
| 52d62dbc96 | |||
| d7a49d2b5c | |||
| 83018639fa | |||
| 99aacb5d81 | |||
| dbd98dff37 | |||
| 647e13e988 | |||
| db745b1bd9 | |||
| d602e77ed3 | |||
| e80569906c | |||
| f67fc157ed | |||
| f18d953a3a | |||
| 1b43bfda74 | |||
| e8c9edb998 | |||
| ea53136068 | |||
| f93c2b8be6 | |||
| 58a089b812 | |||
| 9fe71c49bd | |||
| 39dcb37d51 | |||
| 8e3704f5c4 | |||
| f60dd288ff | |||
| 391a1c23c0 | |||
| 9ea257a4fc | |||
| e860fc9214 | |||
| d054fce432 | |||
| ea73c15738 | |||
| 4d17699775 | |||
| 51af268857 | |||
| 661f8e6267 | |||
| 96391b0706 | |||
| 0fa4a9aae2 | |||
| 9a994da7f8 | |||
| b19bd7537e | |||
| 3fa8778084 | |||
| 1b424ab62d | |||
|
|
4b8f4d288a | ||
|
|
2ad899d787 | ||
| 3a2653d02f | |||
| 7e84ddeaf9 | |||
| 7a9c7a93ff | |||
| 0b02cc82b5 | |||
| dd766206f7 | |||
| 6919513165 | |||
| 385103a0bf | |||
| a3b986674e | |||
| 604ce9ea16 | |||
| d94c1733be | |||
| 44888520cb | |||
| 0d1e240227 | |||
| b702287cfb | |||
| 248d3a6722 | |||
| 35d954ff0f | |||
| c3bea3bb46 | |||
| 7a8c397538 | |||
| 745e93d28d | |||
| c211741a30 | |||
| 393a972e58 | |||
| 8d47b6c66f | |||
| dab95a0d87 | |||
| e151b8610a | |||
| 8784b1d135 | |||
| d1411ad1df | |||
| fd4ab86fff | |||
| 0e2542d32b | |||
| 42fc2d9136 | |||
| 7b3f29ee73 | |||
| 1d54acccbc | |||
| 3e53cb6664 | |||
| 74b540cac2 | |||
| 595aebd8a5 | |||
| 840ddcdce9 | |||
| 38e22b3390 | |||
| 11e5a76977 | |||
| 251c1b7573 | |||
| 1c2bfde005 | |||
| 795726aea5 |
72
.gitea/workflows/deploy.yml
Normal file
72
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: Deploy on Master Change
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code (with full history + correct branch)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Lấy toàn bộ history
|
||||||
|
ref: master # Đảm bảo checkout đúng branch master
|
||||||
|
|
||||||
|
- name: Deploy to server
|
||||||
|
env:
|
||||||
|
SSH_IP: ${{ secrets.SSH_IP }}
|
||||||
|
SSH_PORT: ${{ secrets.SSH_PORT }}
|
||||||
|
SSH_USER: ${{ secrets.SSH_USER }}
|
||||||
|
SSH_PASS: ${{ secrets.SSH_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
# Cài sshpass
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y sshpass
|
||||||
|
|
||||||
|
echo "Đang deploy lên server..."
|
||||||
|
|
||||||
|
sshpass -p "$SSH_PASS" ssh -o StrictHostKeyChecking=no -p "$SSH_PORT" "$SSH_USER@$SSH_IP" << 'EOF'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /var/www/rikkei_simple_care
|
||||||
|
|
||||||
|
echo "Force update code từ remote (hỗ trợ cả force push)"
|
||||||
|
git fetch --all
|
||||||
|
git reset --hard origin/master
|
||||||
|
git clean -fd
|
||||||
|
|
||||||
|
# Load NVM for node/npm
|
||||||
|
export NVM_DIR="$HOME/.nvm"
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||||
|
|
||||||
|
echo "Build management frontend..."
|
||||||
|
cd management
|
||||||
|
npm install
|
||||||
|
VITE_API_BASE=https://sv.rikkeiraia.org/api npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "Build storage server..."
|
||||||
|
cd storage_server
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
pm2 delete storage_server || true
|
||||||
|
pm2 start dist/main.js --name storage_server
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "Build server backend..."
|
||||||
|
cd server
|
||||||
|
go build -o serverlinux main.go
|
||||||
|
pm2 delete raia_v3_server || pm2 delete serverlinux || true
|
||||||
|
pm2 start ./serverlinux --name raia_v3_server
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
pm2 save # lưu lại trạng thái pm2
|
||||||
|
|
||||||
|
echo "Deploy thành công!"
|
||||||
|
EOF
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Production builds
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDEs and OS files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Upload and storage directories (CRITICAL: Ignore to prevent git clean -fd from deleting them)
|
||||||
|
server/uploads/
|
||||||
|
storage/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
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;
|
||||||
BIN
client/.DS_Store
vendored
Normal file
BIN
client/.DS_Store
vendored
Normal file
Binary file not shown.
86
client/INSTALL_LINUX.md
Normal file
86
client/INSTALL_LINUX.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Hướng dẫn Cài đặt & Chạy ứng dụng Simple Care trên Linux
|
||||||
|
|
||||||
|
Tài liệu này hướng dẫn cách cài đặt công cụ lập trình, cài đặt thư viện chạy và tiến hành biên dịch ứng dụng Simple Care trên môi trường Linux.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Cho Nhà phát triển (Developer - Build ứng dụng)
|
||||||
|
|
||||||
|
### Bước 1: Cài đặt thư viện hệ thống
|
||||||
|
Cài đặt trình biên dịch C và các thư viện Webview (GTK/WebKit) tùy vào hệ điều hành đang dùng:
|
||||||
|
|
||||||
|
* **Ubuntu / Debian / Linux Mint:**
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y build-essential libgtk-3-dev libwebkit2gtk-4.1-dev libx11-dev pkg-config zenity libcanberra-gtk3-module
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Fedora / RHEL:**
|
||||||
|
```bash
|
||||||
|
sudo dnf groupinstall "Development Tools"
|
||||||
|
sudo dnf install -y gtk3-devel webkit2gtk4.1-devel libX11-devel pkgconf-pkg-config zenity
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Arch Linux:**
|
||||||
|
```bash
|
||||||
|
sudo pacman -Syu --needed base-devel gtk3 webkit2gtk-4.1 libx11 pkgconf zenity
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 2: Cài đặt Go & Node.js & Wails CLI
|
||||||
|
Nếu chưa cài đặt bộ công cụ biên dịch:
|
||||||
|
1. Tải và cài đặt **Go** (bản 1.23+): [golang.org](https://go.dev/dl/)
|
||||||
|
2. Tải và cài đặt **Node.js** (bản 18 hoặc 20 LTS): [nodejs.org](https://nodejs.org/)
|
||||||
|
3. Cài đặt **Wails CLI** thông qua Go:
|
||||||
|
```bash
|
||||||
|
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
|
||||||
|
```
|
||||||
|
4. Cài đặt công cụ mã hóa **Garble**:
|
||||||
|
```bash
|
||||||
|
go install mvdan.cc/garble@v0.13.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bước 3: Tiến hành Biên dịch (Build)
|
||||||
|
Chạy script tự động phát hiện hệ điều hành để build bản bảo mật (obfuscated):
|
||||||
|
```bash
|
||||||
|
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.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.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
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y libwebkit2gtk-4.1-0 zenity
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Fedora / RHEL:**
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y webkit2gtk4.1 zenity
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Arch Linux:**
|
||||||
|
```bash
|
||||||
|
sudo pacman -Sy webkit2gtk-4.1 zenity
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.3
|
||||||
|
./simple_care_v1.3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Lệnh dọn dẹp bộ nhớ đệm (Clean cache)
|
||||||
|
Nếu thư mục `client/build/` xuất hiện nhiều thư mục đệm tạm thời (dạng số hexa `00` đến `ff`), bạn có thể dọn dẹp bằng lệnh sau:
|
||||||
|
```bash
|
||||||
|
find build -mindepth 1 -maxdepth 1 -type d -name "[0-9a-f][0-9a-f]" -exec rm -rf {} +
|
||||||
|
```
|
||||||
@@ -16,4 +16,18 @@ to this in your browser, and you can call your Go code from devtools.
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `wails build`.
|
### Windows
|
||||||
|
To build for Windows:
|
||||||
|
- Run `wails build` (or `wails build -platform windows/amd64`)
|
||||||
|
- Or use the PowerShell build script: `./build.ps1`
|
||||||
|
|
||||||
|
### macOS (ARM & Intel)
|
||||||
|
To build for macOS:
|
||||||
|
- To build Apple Silicon (ARM64) target: `wails build -platform darwin/arm64`
|
||||||
|
- To build Intel (AMD64) target: `wails build -platform darwin/amd64`
|
||||||
|
- To build a Universal macOS binary: `wails build -platform darwin/universal`
|
||||||
|
- Alternatively, run the helper script to build both ARM64 and AMD64 targets:
|
||||||
|
```bash
|
||||||
|
chmod +x build.sh
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|||||||
1487
client/app.go
1487
client/app.go
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,51 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
Write-Host "=============================================" -ForegroundColor Cyan
|
Write-Host "=============================================" -ForegroundColor Cyan
|
||||||
Write-Host " BUILDING WAILS CLIENT " -ForegroundColor Cyan
|
Write-Host " BUILDING SECURE WAILS CLIENT " -ForegroundColor Cyan
|
||||||
Write-Host "=============================================" -ForegroundColor Cyan
|
Write-Host "=============================================" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Setup Go path for tools like garble
|
||||||
|
try {
|
||||||
|
$goPath = go env GOPATH
|
||||||
|
if ($goPath) {
|
||||||
|
$goBin = Join-Path $goPath "bin"
|
||||||
|
if (Test-Path $goBin) {
|
||||||
|
if ($env:PATH -notlike "*$goBin*") {
|
||||||
|
$env:PATH = "$goBin;$env:PATH"
|
||||||
|
Write-Host "[*] Added $goBin to PATH temporarily" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "[!] Could not resolve GOPATH. Proceeding with default PATH..." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure Garble is installed
|
||||||
|
$garbleInstalled = $null -ne (Get-Command garble -ErrorAction SilentlyContinue)
|
||||||
|
if (-not $garbleInstalled) {
|
||||||
|
Write-Host "[*] Garble is not installed. Installing mvdan.cc/garble@latest..." -ForegroundColor Cyan
|
||||||
|
try {
|
||||||
|
go install mvdan.cc/garble@latest
|
||||||
|
Write-Host "[+] Garble installed successfully!" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "[!] Failed to install Garble. Will fall back to standard Go compiler." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Check if wails is installed
|
# Check if wails is installed
|
||||||
$wailsInstalled = $null -ne (Get-Command wails -ErrorAction SilentlyContinue)
|
$wailsInstalled = $null -ne (Get-Command wails -ErrorAction SilentlyContinue)
|
||||||
|
|
||||||
if ($wailsInstalled) {
|
if ($wailsInstalled) {
|
||||||
Write-Host "[*] Found Wails CLI. Building via Wails..." -ForegroundColor Green
|
Write-Host "[*] Found Wails CLI. Building via Wails with Obfuscation..." -ForegroundColor Green
|
||||||
try {
|
try {
|
||||||
wails build
|
$garbleInstalled = $null -ne (Get-Command garble -ErrorAction SilentlyContinue)
|
||||||
|
if ($garbleInstalled) {
|
||||||
|
Write-Host "[*] Compiling using Garble compiler..." -ForegroundColor Green
|
||||||
|
wails build -clean -compiler garble -obfuscated -trimpath -m -ldflags "-s -w"
|
||||||
|
} else {
|
||||||
|
Write-Host "[!] Garble not available. Compiling with default Go compiler..." -ForegroundColor Yellow
|
||||||
|
wails build -clean -ldflags "-s -w"
|
||||||
|
}
|
||||||
Write-Host "[+] Build completed successfully using Wails CLI!" -ForegroundColor Green
|
Write-Host "[+] Build completed successfully using Wails CLI!" -ForegroundColor Green
|
||||||
exit 0
|
exit 0
|
||||||
} catch {
|
} catch {
|
||||||
@@ -53,8 +88,14 @@ try {
|
|||||||
Write-Host "[*] Building Go application..." -ForegroundColor Cyan
|
Write-Host "[*] Building Go application..." -ForegroundColor Cyan
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# -H windowsgui: prevents console window from flashing on startup
|
$garbleInstalled = $null -ne (Get-Command garble -ErrorAction SilentlyContinue)
|
||||||
go build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go
|
if ($garbleInstalled) {
|
||||||
|
Write-Host "[*] Compiling manual build with Garble..." -ForegroundColor Green
|
||||||
|
garble build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go
|
||||||
|
} else {
|
||||||
|
Write-Host "[!] Garble not available. Compiling manual build with standard Go..." -ForegroundColor Yellow
|
||||||
|
go build -ldflags="-s -w -H windowsgui" -o client.exe main.go app.go
|
||||||
|
}
|
||||||
Write-Host "[+] Build completed successfully! Generated client.exe" -ForegroundColor Green
|
Write-Host "[+] Build completed successfully! Generated client.exe" -ForegroundColor Green
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host "[-] Go build failed." -ForegroundColor Red
|
Write-Host "[-] Go build failed." -ForegroundColor Red
|
||||||
|
|||||||
66
client/build.sh
Executable file
66
client/build.sh
Executable file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Change directory to client folder if not already there
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "============================================="
|
||||||
|
echo " BUILDING SECURE WAILS CLIENT "
|
||||||
|
echo "============================================="
|
||||||
|
|
||||||
|
# Setup Go path for tools like garble and wails
|
||||||
|
GOPATH=$(go env GOPATH)
|
||||||
|
if [ -n "$GOPATH" ] && [ -d "$GOPATH/bin" ]; then
|
||||||
|
export PATH="$GOPATH/bin:$PATH"
|
||||||
|
fi
|
||||||
|
if [ -d "$HOME/go/bin" ]; then
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure Garble is installed
|
||||||
|
if ! command -v garble &> /dev/null; then
|
||||||
|
echo "[*] Garble is not installed. Installing mvdan.cc/garble@latest..."
|
||||||
|
go install mvdan.cc/garble@latest || echo "[!] Failed to install Garble. Will use default Go compiler."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if wails is installed
|
||||||
|
WAILS_CMD="wails"
|
||||||
|
if ! command -v wails &> /dev/null; then
|
||||||
|
if [ -f "$HOME/go/bin/wails" ]; then
|
||||||
|
WAILS_CMD="$HOME/go/bin/wails"
|
||||||
|
echo "[*] Found Wails CLI at $WAILS_CMD"
|
||||||
|
else
|
||||||
|
echo "[-] Wails CLI not found. Please install Wails first."
|
||||||
|
echo " Run: go install github.com/wailsapp/wails/v2/cmd/wails@latest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set compiler and obfuscation flags if garble is available
|
||||||
|
BUILD_FLAGS=(-clean -obfuscated -trimpath -m)
|
||||||
|
if command -v garble &> /dev/null; then
|
||||||
|
echo "[*] Garble found. Compiling with Obfuscator..."
|
||||||
|
BUILD_FLAGS+=(-compiler garble)
|
||||||
|
else
|
||||||
|
echo "[!] Garble not found. Compiling with default Go compiler..."
|
||||||
|
fi
|
||||||
|
BUILD_FLAGS+=(-ldflags "-s -w")
|
||||||
|
|
||||||
|
# Detect host OS
|
||||||
|
HOST_OS=$(go env GOOS)
|
||||||
|
|
||||||
|
if [ "$HOST_OS" = "darwin" ]; then
|
||||||
|
echo "[*] Building macOS Apple Silicon (arm64)..."
|
||||||
|
"$WAILS_CMD" build -platform darwin/arm64 "${BUILD_FLAGS[@]}"
|
||||||
|
|
||||||
|
echo "[*] Building macOS Intel (amd64)..."
|
||||||
|
"$WAILS_CMD" build -platform darwin/amd64 "${BUILD_FLAGS[@]}"
|
||||||
|
echo "[+] macOS arm64 and amd64 builds completed successfully!"
|
||||||
|
elif [ "$HOST_OS" = "linux" ]; then
|
||||||
|
echo "[*] Building Linux Intel/AMD64 (amd64)..."
|
||||||
|
"$WAILS_CMD" build -platform linux/amd64 -tags webkit2_41 "${BUILD_FLAGS[@]}"
|
||||||
|
echo "[+] Linux amd64 build completed successfully!"
|
||||||
|
else
|
||||||
|
echo "[!] Unsupported host OS: $HOST_OS. Please build manually using 'wails build'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
BIN
client/build/.DS_Store
vendored
Normal file
BIN
client/build/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 531 KiB |
@@ -64,5 +64,13 @@
|
|||||||
<key>NSAllowsLocalNetworking</key>
|
<key>NSAllowsLocalNetworking</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền vị trí để xác thực mạng Wi-Fi phòng thi.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập camera để giám sát thi và xác thực khuôn mặt sinh viên.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập microphone để giám sát âm thanh phòng thi.</string>
|
||||||
|
<key>NSScreenCaptureUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền chia sẻ màn hình để giám sát và ghi lại quá trình thi.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -59,5 +59,14 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</array>
|
</array>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền vị trí để xác thực mạng Wi-Fi phòng thi.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập camera để giám sát thi và xác thực khuôn mặt sinh viên.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền truy cập microphone để giám sát âm thanh phòng thi.</string>
|
||||||
|
<key>NSScreenCaptureUsageDescription</key>
|
||||||
|
<string>Ứng dụng cần quyền chia sẻ màn hình để giám sát và ghi lại quá trình thi.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
||||||
|
|||||||
1
client/build/trim.txt
Normal file
1
client/build/trim.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1782971860
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 77 KiB |
776
client/frontend/package-lock.json
generated
776
client/frontend/package-lock.json
generated
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.3.0",
|
||||||
|
"dependencies": {
|
||||||
|
"pdfjs-dist": "^3.11.174"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^3.0.7"
|
"vite": "^3.0.7"
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,202 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/node-pre-gyp": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"https-proxy-agent": "^5.0.0",
|
||||||
|
"make-dir": "^3.1.0",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"nopt": "^5.0.0",
|
||||||
|
"npmlog": "^5.0.1",
|
||||||
|
"rimraf": "^3.0.2",
|
||||||
|
"semver": "^7.3.5",
|
||||||
|
"tar": "^6.1.11"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-pre-gyp": "bin/node-pre-gyp"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/abbrev": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/aproba": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/are-we-there-yet": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"delegates": "^1.0.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
|
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/canvas": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/node-pre-gyp": "^1.0.0",
|
||||||
|
"nan": "^2.17.0",
|
||||||
|
"simple-get": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-support": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"color-support": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/concat-map": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/console-control-strings": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/delegates": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/es-errors": {
|
"node_modules/es-errors": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
@@ -433,6 +632,39 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fs-minipass": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-minipass/node_modules/minipass": {
|
||||||
|
"version": "3.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||||
|
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs.realpath": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -458,6 +690,57 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gauge": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"aproba": "^1.0.3 || ^2.0.0",
|
||||||
|
"color-support": "^1.1.2",
|
||||||
|
"console-control-strings": "^1.0.0",
|
||||||
|
"has-unicode": "^2.0.1",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"signal-exit": "^3.0.0",
|
||||||
|
"string-width": "^4.2.3",
|
||||||
|
"strip-ansi": "^6.0.1",
|
||||||
|
"wide-align": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "7.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||||
|
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^3.1.1",
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"path-is-absolute": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-unicode": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
@@ -471,6 +754,39 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inflight": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||||
|
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.3.0",
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/is-core-module": {
|
"node_modules/is-core-module": {
|
||||||
"version": "2.16.2",
|
"version": "2.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||||
@@ -487,6 +803,132 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir/node_modules/semver": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^3.0.0",
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib/node_modules/minipass": {
|
||||||
|
"version": "3.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||||
|
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
|
||||||
|
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
@@ -506,6 +948,87 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nopt": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"abbrev": "1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nopt": "bin/nopt.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/npmlog": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
|
||||||
|
"deprecated": "This package is no longer supported.",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"are-we-there-yet": "^2.0.0",
|
||||||
|
"console-control-strings": "^1.1.0",
|
||||||
|
"gauge": "^3.0.0",
|
||||||
|
"set-blocking": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-is-absolute": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -513,6 +1036,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path2d-polyfill": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "3.11.174",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
|
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvas": "^2.11.2",
|
||||||
|
"path2d-polyfill": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -549,6 +1095,21 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -571,6 +1132,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rimraf": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||||
|
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^7.1.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rimraf": "bin.js"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "2.80.0",
|
"version": "2.80.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
|
||||||
@@ -587,6 +1165,87 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/simple-concat": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/simple-get": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"decompress-response": "^4.2.0",
|
||||||
|
"once": "^1.3.1",
|
||||||
|
"simple-concat": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -597,6 +1256,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
@@ -610,6 +1307,39 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||||
|
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^2.0.0",
|
||||||
|
"fs-minipass": "^2.0.0",
|
||||||
|
"minipass": "^5.0.0",
|
||||||
|
"minizlib": "^2.1.1",
|
||||||
|
"mkdirp": "^1.0.3",
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "3.2.11",
|
"version": "3.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz",
|
||||||
@@ -659,6 +1389,48 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wide-align": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^1.0.2 || 2 || 3 || 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -9,5 +9,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^3.0.7"
|
"vite": "^3.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pdfjs-dist": "^3.11.174"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
5fbf12469d224a93954efecb5886e8a6
|
792441f7861d7badf1335f3778fcdebd
|
||||||
@@ -18,10 +18,12 @@
|
|||||||
--offline-light: #fdeaea;
|
--offline-light: #fdeaea;
|
||||||
--shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05);
|
--shadow-sm: 0 1px 3px rgba(26, 35, 50, 0.05);
|
||||||
--shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07);
|
--shadow-md: 0 4px 16px rgba(26, 35, 50, 0.07);
|
||||||
--radius-sm: 8px;
|
--radius-sm: 7px;
|
||||||
--radius-md: 12px;
|
--radius-md: 10px;
|
||||||
--radius-lg: 16px;
|
--radius-lg: 12px;
|
||||||
--transition: 0.2s ease;
|
--transition: 0.2s ease;
|
||||||
|
--page-pad: 0.75rem 0.9rem;
|
||||||
|
--card-pad: 0.95rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -32,7 +34,8 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -40,18 +43,22 @@ body {
|
|||||||
background-color: var(--bg-main);
|
background-color: var(--bg-main);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
overflow: hidden;
|
font-size: 14px;
|
||||||
height: 100%;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 100%;
|
||||||
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
max-height: 100vh;
|
height: auto;
|
||||||
display: flex;
|
max-height: none;
|
||||||
overflow: hidden;
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cards ── */
|
/* ── Cards ── */
|
||||||
@@ -60,7 +67,7 @@ body {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
padding: 1.75rem;
|
padding: var(--card-pad);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +77,10 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
padding: 1.5rem;
|
padding: 1rem;
|
||||||
background: var(--bg-main);
|
background: var(--bg-main);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-prompt-container .card {
|
.login-prompt-container .card {
|
||||||
@@ -94,8 +101,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo-img {
|
.brand-logo-img {
|
||||||
width: 42px;
|
width: 34px;
|
||||||
height: 42px;
|
height: 34px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -103,8 +110,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-brand-row .brand-logo-img {
|
.login-brand-row .brand-logo-img {
|
||||||
width: 48px;
|
width: 40px;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-logo {
|
.login-logo {
|
||||||
@@ -133,6 +140,10 @@ body {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-brand-name .app-version {
|
||||||
|
font-size: 0.72em;
|
||||||
|
}
|
||||||
|
|
||||||
.login-brand-sub {
|
.login-brand-sub {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -159,11 +170,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
max-height: 100vh;
|
height: auto;
|
||||||
padding: 1.25rem 1.5rem;
|
max-height: none;
|
||||||
|
padding: var(--page-pad);
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
animation: fadeIn 0.35s ease-out;
|
animation: fadeIn 0.35s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +184,14 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.65rem;
|
||||||
padding-bottom: 0.85rem;
|
padding-bottom: 0.55rem;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--bg-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -184,15 +201,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 38px;
|
width: 32px;
|
||||||
height: 38px;
|
height: 32px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.85rem;
|
font-size: 0.75rem;
|
||||||
color: white;
|
color: white;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -205,25 +222,36 @@ body {
|
|||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 1.05rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.2;
|
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 {
|
.brand-sub {
|
||||||
font-size: 0.7rem;
|
font-size: 0.62rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-grid {
|
.main-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 260px) 1fr;
|
grid-template-columns: minmax(180px, 220px) 1fr;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
flex: 1;
|
flex: none;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Profile Card ── */
|
/* ── Profile Card ── */
|
||||||
@@ -232,25 +260,25 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
height: 100%;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow: visible;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding-top: 1rem;
|
padding-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-container {
|
.avatar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 84px;
|
width: 68px;
|
||||||
height: 84px;
|
height: 68px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 3px solid var(--accent-light);
|
border: 2px solid var(--accent-light);
|
||||||
padding: 3px;
|
padding: 2px;
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +304,7 @@ body {
|
|||||||
|
|
||||||
.student-name {
|
.student-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
@@ -285,12 +313,12 @@ body {
|
|||||||
.student-code {
|
.student-code {
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
padding: 3px 10px;
|
padding: 2px 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.3rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.35rem;
|
||||||
border: 1px solid rgba(187, 33, 38, 0.15);
|
border: 1px solid rgba(187, 33, 38, 0.15);
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -299,22 +327,22 @@ body {
|
|||||||
.student-email {
|
.student-email {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.82rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
margin: 1.25rem 0;
|
margin: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-info-row {
|
.profile-info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 0.82rem;
|
font-size: 0.75rem;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-info-row span {
|
.profile-info-row span {
|
||||||
@@ -330,17 +358,17 @@ body {
|
|||||||
.right-column {
|
.right-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.85rem;
|
gap: 0.65rem;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
height: 100%;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner {
|
.status-banner {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.55rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.55rem;
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -393,13 +421,13 @@ body {
|
|||||||
.exam-panel {
|
.exam-panel {
|
||||||
border: 1px solid #c4b5fd;
|
border: 1px solid #c4b5fd;
|
||||||
background: linear-gradient(135deg, #faf5ff 0%, #fff 100%);
|
background: linear-gradient(135deg, #faf5ff 0%, #fff 100%);
|
||||||
padding: 1rem 1.15rem;
|
padding: 0.75rem 0.9rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exam-panel-desc {
|
.exam-panel-desc {
|
||||||
margin: 0 0 0.85rem;
|
margin: 0 0 0.65rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,15 +438,237 @@ body {
|
|||||||
align-items: center;
|
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 {
|
.exam-wait, .exam-done {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exam-resources {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px dashed #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-resources-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #6d28d9;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-resources-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10050;
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
display: none;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-overlay.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-shell {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||||
|
max-height: calc(100vh - 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #f8fafc;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
height: calc(100vh - 6.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-content {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #cbd5e1;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-content.is-ready {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-loading,
|
||||||
|
.exam-viewer-error {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-loading.is-active,
|
||||||
|
.exam-viewer-error.is-active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-viewer-error.is-active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-pdf-page-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-pdf-page {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-view-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-view-text {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-resource-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: 1px solid #ede9fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-resource-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-resource-name {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-resource-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 0.65rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid--two {
|
.stats-grid--two {
|
||||||
@@ -767,9 +1017,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shifts-wrap {
|
.shifts-wrap {
|
||||||
flex: 1;
|
flex: none;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shifts-table {
|
.shifts-table {
|
||||||
@@ -838,14 +1089,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-banner-icon {
|
.status-banner-icon {
|
||||||
width: 36px;
|
width: 30px;
|
||||||
height: 36px;
|
height: 30px;
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.1rem;
|
font-size: 0.95rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +1109,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-banner-value {
|
.status-banner-value {
|
||||||
font-size: 0.9rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -866,19 +1117,19 @@ body {
|
|||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.85rem;
|
gap: 0.65rem;
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.65rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon-wrap {
|
.stat-icon-wrap {
|
||||||
width: 40px;
|
width: 34px;
|
||||||
height: 40px;
|
height: 34px;
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,7 +1149,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 0.95rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -910,11 +1161,11 @@ body {
|
|||||||
.clocks-card {
|
.clocks-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.25rem;
|
||||||
flex: 1;
|
flex: none;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.45rem 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -971,11 +1222,11 @@ body {
|
|||||||
|
|
||||||
/* ── Buttons ── */
|
/* ── Buttons ── */
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.65rem 1.15rem;
|
padding: 0.45rem 0.85rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: none;
|
border: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.78rem;
|
||||||
font-family: 'Be Vietnam Pro', sans-serif;
|
font-family: 'Be Vietnam Pro', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
@@ -997,8 +1248,8 @@ body {
|
|||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 0.5rem 0.9rem;
|
padding: 0.35rem 0.7rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-logout:hover {
|
.btn-logout:hover {
|
||||||
@@ -1007,6 +1258,69 @@ body {
|
|||||||
border-color: rgba(214, 48, 49, 0.25);
|
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 ── */
|
/* ── Animations ── */
|
||||||
@keyframes floatIn {
|
@keyframes floatIn {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
|||||||
78
client/frontend/src/examViewer.js
Normal file
78
client/frontend/src/examViewer.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
|
||||||
|
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||||
|
|
||||||
|
function decodeBase64(b64) {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectExamViewKind(fileName, mime, bytes) {
|
||||||
|
if (bytes && bytes.length >= 4) {
|
||||||
|
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return 'pdf';
|
||||||
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image';
|
||||||
|
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image';
|
||||||
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image';
|
||||||
|
}
|
||||||
|
const name = String(fileName || '').toLowerCase();
|
||||||
|
const type = String(mime || '').toLowerCase();
|
||||||
|
if (type.includes('pdf') || name.endsWith('.pdf')) return 'pdf';
|
||||||
|
if (type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/.test(name)) return 'image';
|
||||||
|
if (type.startsWith('text/') || name.endsWith('.txt')) return 'text';
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderSecurePdf(container, bytes) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
|
||||||
|
const pad = 16;
|
||||||
|
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
|
||||||
|
const maxWidth = Math.max(360, width - pad * 2);
|
||||||
|
|
||||||
|
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
|
||||||
|
const page = await pdf.getPage(pageNum);
|
||||||
|
const base = page.getViewport({ scale: 1 });
|
||||||
|
const scale = Math.min(1.6, maxWidth / base.width);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'exam-pdf-page-wrap';
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'exam-pdf-page';
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
wrap.appendChild(canvas);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: canvas.getContext('2d'),
|
||||||
|
viewport,
|
||||||
|
}).promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSecureImage(container, bytes, mime) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const blob = new Blob([bytes], { type: mime || 'image/png' });
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'exam-view-image';
|
||||||
|
img.alt = 'Tài nguyên';
|
||||||
|
img.draggable = false;
|
||||||
|
img.src = URL.createObjectURL(blob);
|
||||||
|
container.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSecureText(container, bytes) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'exam-view-text';
|
||||||
|
pre.textContent = new TextDecoder('utf-8').decode(bytes);
|
||||||
|
container.appendChild(pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeExamFilePayload(payload) {
|
||||||
|
return decodeBase64(payload.data || payload);
|
||||||
|
}
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import logoUrl from './assets/logo.jpeg';
|
import logoUrl from './assets/logo.jpeg';
|
||||||
|
import {
|
||||||
|
decodeExamFilePayload,
|
||||||
|
detectExamViewKind,
|
||||||
|
renderSecureImage,
|
||||||
|
renderSecurePdf,
|
||||||
|
renderSecureText,
|
||||||
|
} from './examViewer.js';
|
||||||
|
import * as GoApp from '../wailsjs/go/main/App.js';
|
||||||
|
|
||||||
|
// Map Wails bindings to window.go.main.App for backward compatibility with obfuscated builds
|
||||||
|
window.go = window.go || {};
|
||||||
|
window.go.main = window.go.main || {};
|
||||||
|
if (typeof window.go.main.App === 'undefined') {
|
||||||
|
window.go.main.App = GoApp;
|
||||||
|
window.go.main._custom = true;
|
||||||
|
}
|
||||||
|
|
||||||
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
|
const brandLogoHtml = `<img src="${logoUrl}" alt="Simple Care" class="brand-logo-img" />`;
|
||||||
|
const APP_VERSION = '1.3';
|
||||||
|
|
||||||
// Trạng thái cục bộ
|
// Trạng thái cục bộ
|
||||||
let loggedIn = false;
|
let loggedIn = false;
|
||||||
@@ -47,7 +64,10 @@ webcamCanvas.style.display = 'none';
|
|||||||
document.body.appendChild(webcamCanvas);
|
document.body.appendChild(webcamCanvas);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
if (typeof window.go === 'undefined' || typeof window.go.main === 'undefined') {
|
const ready = (typeof window.ObfuscatedCall === 'function') ||
|
||||||
|
(typeof window.go !== 'undefined' && typeof window.go.main !== 'undefined' && !window.go.main._custom);
|
||||||
|
|
||||||
|
if (!ready || typeof window.runtime === 'undefined') {
|
||||||
setTimeout(init, 200);
|
setTimeout(init, 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,9 +88,12 @@ function init() {
|
|||||||
});
|
});
|
||||||
window.runtime.EventsOn('exam:paper-sent', () => {
|
window.runtime.EventsOn('exam:paper-sent', () => {
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
|
examPaperFiles = null;
|
||||||
|
examPaperFilesRoomId = 0;
|
||||||
|
lastExamPanelKey = '';
|
||||||
window.go.main.App.GetStats().then((s) => {
|
window.go.main.App.GetStats().then((s) => {
|
||||||
stats = { ...stats, ...s };
|
stats = { ...stats, ...s };
|
||||||
updateExamPanel();
|
updateExamPanel(true);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -87,6 +110,7 @@ async function checkLogin() {
|
|||||||
startStatsTicker();
|
startStatsTicker();
|
||||||
ensureChatWidget();
|
ensureChatWidget();
|
||||||
updateChatBadge();
|
updateChatBadge();
|
||||||
|
if (stats.monitorMode === 'exam') updateExamPanel();
|
||||||
} else {
|
} else {
|
||||||
loggedIn = false;
|
loggedIn = false;
|
||||||
renderLoginPrompt();
|
renderLoginPrompt();
|
||||||
@@ -103,7 +127,7 @@ function renderLoginPrompt() {
|
|||||||
<div class="login-brand-row">
|
<div class="login-brand-row">
|
||||||
${brandLogoHtml}
|
${brandLogoHtml}
|
||||||
<div class="login-brand-text">
|
<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 class="login-brand-sub">Rikkei Education</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,6 +202,201 @@ function renderShiftsTable(shifts) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let examPaperFiles = null;
|
||||||
|
let examPaperFilesRoomId = 0;
|
||||||
|
let examPaperFilesLoading = false;
|
||||||
|
let lastExamPanelKey = '';
|
||||||
|
let examViewerObjectUrl = null;
|
||||||
|
|
||||||
|
function wireExamViewerGuards(overlay) {
|
||||||
|
overlay.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
overlay.addEventListener('keydown', (e) => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (key === 'p' || key === 's')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExamViewer() {
|
||||||
|
let overlay = document.getElementById('exam-viewer-overlay');
|
||||||
|
if (overlay) return overlay;
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'exam-viewer-overlay';
|
||||||
|
overlay.className = 'exam-viewer-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="exam-viewer-shell">
|
||||||
|
<div class="exam-viewer-bar">
|
||||||
|
<button type="button" class="btn btn-secondary" id="exam-viewer-close">← Quay lại</button>
|
||||||
|
<span class="exam-viewer-title" id="exam-viewer-title"></span>
|
||||||
|
</div>
|
||||||
|
<div class="exam-viewer-body" id="exam-viewer-body">
|
||||||
|
<div class="exam-viewer-content" id="exam-viewer-content"></div>
|
||||||
|
<div class="exam-viewer-loading" id="exam-viewer-loading">Đang tải...</div>
|
||||||
|
<div class="exam-viewer-error" id="exam-viewer-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('#exam-viewer-close').addEventListener('click', closeExamViewer);
|
||||||
|
wireExamViewerGuards(overlay);
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExamViewerLoading(loading) {
|
||||||
|
const loadingEl = document.getElementById('exam-viewer-loading');
|
||||||
|
const contentEl = document.getElementById('exam-viewer-content');
|
||||||
|
const errorEl = document.getElementById('exam-viewer-error');
|
||||||
|
if (loadingEl) loadingEl.classList.toggle('is-active', loading);
|
||||||
|
if (contentEl) contentEl.classList.toggle('is-ready', !loading);
|
||||||
|
if (errorEl) errorEl.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExamViewerError(message) {
|
||||||
|
const loadingEl = document.getElementById('exam-viewer-loading');
|
||||||
|
const contentEl = document.getElementById('exam-viewer-content');
|
||||||
|
const errorEl = document.getElementById('exam-viewer-error');
|
||||||
|
if (loadingEl) loadingEl.classList.remove('is-active');
|
||||||
|
if (contentEl) contentEl.classList.remove('is-ready');
|
||||||
|
if (errorEl) {
|
||||||
|
errorEl.classList.add('is-active');
|
||||||
|
errorEl.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetExamViewerPanels() {
|
||||||
|
const loadingEl = document.getElementById('exam-viewer-loading');
|
||||||
|
const contentEl = document.getElementById('exam-viewer-content');
|
||||||
|
const errorEl = document.getElementById('exam-viewer-error');
|
||||||
|
if (loadingEl) loadingEl.classList.remove('is-active');
|
||||||
|
if (contentEl) contentEl.classList.remove('is-ready');
|
||||||
|
if (errorEl) errorEl.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExamViewerContent() {
|
||||||
|
if (examViewerObjectUrl) {
|
||||||
|
URL.revokeObjectURL(examViewerObjectUrl);
|
||||||
|
examViewerObjectUrl = null;
|
||||||
|
}
|
||||||
|
const contentEl = document.getElementById('exam-viewer-content');
|
||||||
|
if (contentEl) contentEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showExamViewer(url, title, fileName = '') {
|
||||||
|
const overlay = ensureExamViewer();
|
||||||
|
document.getElementById('exam-viewer-title').textContent = title || 'Xem trong app';
|
||||||
|
overlay.classList.add('is-open');
|
||||||
|
overlay.focus();
|
||||||
|
clearExamViewerContent();
|
||||||
|
resetExamViewerPanels();
|
||||||
|
setExamViewerLoading(true);
|
||||||
|
|
||||||
|
const contentEl = document.getElementById('exam-viewer-content');
|
||||||
|
if (!contentEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await window.go.main.App.LoadExamViewFile(url);
|
||||||
|
const bytes = decodeExamFilePayload(payload);
|
||||||
|
const mime = payload?.mime || '';
|
||||||
|
const kind = detectExamViewKind(fileName || title, mime, bytes);
|
||||||
|
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
await renderSecurePdf(contentEl, bytes);
|
||||||
|
} else if (kind === 'image') {
|
||||||
|
renderSecureImage(contentEl, bytes, mime);
|
||||||
|
const img = contentEl.querySelector('img');
|
||||||
|
if (img?.src?.startsWith('blob:')) examViewerObjectUrl = img.src;
|
||||||
|
if (img) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
if (img.complete) resolve();
|
||||||
|
else {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('Không hiển thị được ảnh'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (kind === 'text') {
|
||||||
|
renderSecureText(contentEl, bytes);
|
||||||
|
} else {
|
||||||
|
setExamViewerError('Chỉ hỗ trợ xem PDF, ảnh hoặc file text trong app.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExamViewerLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setExamViewerError(e?.message || e || 'Không mở được file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExamViewer() {
|
||||||
|
const overlay = document.getElementById('exam-viewer-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('is-open');
|
||||||
|
clearExamViewerContent();
|
||||||
|
resetExamViewerPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExamResources() {
|
||||||
|
const ex = stats.exam;
|
||||||
|
if (!ex?.paperSent) return '';
|
||||||
|
|
||||||
|
if (examPaperFilesLoading && !examPaperFiles) {
|
||||||
|
return `
|
||||||
|
<div class="exam-resources">
|
||||||
|
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
|
||||||
|
<p class="exam-resources-hint">Đang tải danh sách tài nguyên...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources : [];
|
||||||
|
if (!resources.length) {
|
||||||
|
return `
|
||||||
|
<div class="exam-resources">
|
||||||
|
<div class="exam-resources-title">📎 Tài nguyên kèm đề</div>
|
||||||
|
<p class="exam-resources-hint">Gói đề này không có tài nguyên đính kèm.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = resources.map((r) => `
|
||||||
|
<div class="exam-resource-row">
|
||||||
|
<span class="exam-resource-name" title="${escapeHtml(r.fileName || '')}">${escapeHtml(r.fileName || 'Tài nguyên')}</span>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm btn-exam-res-dl" data-id="${r.id}">Tải về</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
return `
|
||||||
|
<div class="exam-resources">
|
||||||
|
<div class="exam-resources-title">📎 Tài nguyên kèm đề (${resources.length})</div>
|
||||||
|
${rows}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExamPaperFiles(force = false) {
|
||||||
|
const ex = stats.exam;
|
||||||
|
if (!ex || stats.monitorMode !== 'exam' || !ex.paperSent) {
|
||||||
|
examPaperFiles = null;
|
||||||
|
examPaperFilesRoomId = 0;
|
||||||
|
examPaperFilesLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && examPaperFiles && examPaperFilesRoomId === ex.examRoomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
examPaperFilesLoading = true;
|
||||||
|
try {
|
||||||
|
examPaperFiles = await window.go.main.App.GetExamPaperFiles();
|
||||||
|
examPaperFilesRoomId = ex.examRoomId;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GetExamPaperFiles failed:', err);
|
||||||
|
examPaperFiles = { resources: [] };
|
||||||
|
examPaperFilesRoomId = ex.examRoomId;
|
||||||
|
} finally {
|
||||||
|
examPaperFilesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderExamPanel() {
|
function renderExamPanel() {
|
||||||
const ex = stats.exam;
|
const ex = stats.exam;
|
||||||
if (!ex || stats.monitorMode !== 'exam') return '';
|
if (!ex || stats.monitorMode !== 'exam') return '';
|
||||||
@@ -195,20 +414,27 @@ function renderExamPanel() {
|
|||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
<div class="card-title">📝 ${ex.examName || 'Phòng thi'}</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="exam-panel-desc">Bạn đang trong giờ thi. Làm bài theo hướng dẫn của giảng viên.</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">
|
<div class="exam-panel-actions">
|
||||||
${paperBtn}
|
${paperBtn}
|
||||||
${quizBtn}
|
${quizBtn}
|
||||||
${submitBtn}
|
${submitBtn}
|
||||||
</div>
|
</div>
|
||||||
|
${renderExamResources()}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireExamPanel() {
|
function wireExamPanel() {
|
||||||
|
const ex = stats.exam;
|
||||||
const paper = document.getElementById('btn-exam-paper');
|
const paper = document.getElementById('btn-exam-paper');
|
||||||
if (paper) paper.addEventListener('click', () => {
|
if (paper) paper.addEventListener('click', async () => {
|
||||||
window.go.main.App.OpenExamPaper().catch((e) => alert(e?.message || e));
|
try {
|
||||||
|
const url = await window.go.main.App.GetExamPaperViewURL();
|
||||||
|
showExamViewer(url, ex?.paperTitle || 'Đề thi', ex?.paperTitle || 'de.pdf');
|
||||||
|
} catch (e) {
|
||||||
|
alert(e?.message || e || 'Không mở được đề');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const quiz = document.getElementById('btn-exam-quiz');
|
const quiz = document.getElementById('btn-exam-quiz');
|
||||||
if (quiz) quiz.addEventListener('click', () => {
|
if (quiz) quiz.addEventListener('click', () => {
|
||||||
@@ -221,20 +447,44 @@ function wireExamPanel() {
|
|||||||
alert(`Đã nộp bài: ${name}`);
|
alert(`Đã nộp bài: ${name}`);
|
||||||
const fresh = await window.go.main.App.GetStats();
|
const fresh = await window.go.main.App.GetStats();
|
||||||
stats = { ...stats, ...fresh };
|
stats = { ...stats, ...fresh };
|
||||||
|
await loadExamPaperFiles();
|
||||||
updateExamPanel();
|
updateExamPanel();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e?.message || e || 'Nộp bài thất bại');
|
alert(e?.message || e || 'Nộp bài thất bại');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('.btn-exam-res-dl').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = Number(btn.getAttribute('data-id'));
|
||||||
|
try {
|
||||||
|
await window.go.main.App.DownloadExamResource(id);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e?.message || e || 'Tải thất bại');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateExamPanel() {
|
async function updateExamPanel(forceReloadFiles = false) {
|
||||||
const host = document.getElementById('exam-panel-host');
|
const host = document.getElementById('exam-panel-host');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
if (stats.monitorMode !== 'exam' || !stats.exam) {
|
||||||
|
host.innerHTML = '';
|
||||||
|
lastExamPanelKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadExamPaperFiles(forceReloadFiles);
|
||||||
host.innerHTML = renderExamPanel();
|
host.innerHTML = renderExamPanel();
|
||||||
wireExamPanel();
|
wireExamPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function examPanelKey() {
|
||||||
|
const ex = stats.exam;
|
||||||
|
if (!ex) return '';
|
||||||
|
const resCount = Array.isArray(examPaperFiles?.resources) ? examPaperFiles.resources.length : -1;
|
||||||
|
return `${ex.examRoomId}:${ex.paperSent}:${ex.submitted}:${resCount}:${examPaperFilesLoading}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
if (!studentInfo) return;
|
if (!studentInfo) return;
|
||||||
|
|
||||||
@@ -249,7 +499,7 @@ function renderDashboard() {
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
${brandLogoHtml}
|
${brandLogoHtml}
|
||||||
<div class="brand-text">
|
<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>
|
<span class="brand-sub">Rikkei Education</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,6 +545,18 @@ function renderDashboard() {
|
|||||||
|
|
||||||
<div id="exam-panel-host">${renderExamPanel()}</div>
|
<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="stats-grid stats-grid--two">
|
||||||
<div class="card stat-card">
|
<div class="card stat-card">
|
||||||
<div class="stat-icon-wrap">📡</div>
|
<div class="stat-icon-wrap">📡</div>
|
||||||
@@ -343,6 +605,15 @@ function renderDashboard() {
|
|||||||
await window.go.main.App.Logout();
|
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();
|
wireExamPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +756,7 @@ function renderChatMessages(silent = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendStudentChat() {
|
async function sendStudentChat() {
|
||||||
@@ -575,7 +846,17 @@ function startStatsTicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateExamPanel();
|
if (stats.monitorMode === 'exam' && stats.exam) {
|
||||||
|
const panelKey = examPanelKey();
|
||||||
|
if (panelKey !== lastExamPanelKey) {
|
||||||
|
lastExamPanelKey = panelKey;
|
||||||
|
updateExamPanel();
|
||||||
|
}
|
||||||
|
} else if (lastExamPanelKey !== '') {
|
||||||
|
lastExamPanelKey = '';
|
||||||
|
const host = document.getElementById('exam-panel-host');
|
||||||
|
if (host) host.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
const onlineEl = document.getElementById('clock-online');
|
const onlineEl = document.getElementById('clock-online');
|
||||||
if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0);
|
if (onlineEl) onlineEl.innerText = formatDuration(stats.onlineSecs || 0);
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
overflow: hidden;
|
height: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
26
client/frontend/wailsjs/go/main/App.d.ts
vendored
Normal file → Executable file
26
client/frontend/wailsjs/go/main/App.d.ts
vendored
Normal file → Executable file
@@ -5,16 +5,30 @@ export function CheckLoginStatus():Promise<boolean>;
|
|||||||
|
|
||||||
export function ClearChatUnread():Promise<void>;
|
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>>>;
|
export function GetChatConversations():Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>;
|
export function GetChatMessages(arg1:number):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function GetChatUnread():Promise<number>;
|
export function GetChatUnread():Promise<number>;
|
||||||
|
|
||||||
|
export function GetExamPaperFiles():Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetExamPaperViewURL():Promise<string>;
|
||||||
|
|
||||||
|
export function GetExamQuizViewURL():Promise<string>;
|
||||||
|
|
||||||
export function GetStats():Promise<Record<string, any>>;
|
export function GetStats():Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function GetStudentInfo():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>;
|
export function Logout():Promise<void>;
|
||||||
|
|
||||||
export function NavigateToLogin():Promise<void>;
|
export function NavigateToLogin():Promise<void>;
|
||||||
@@ -23,6 +37,18 @@ export function OpenExamPaper():Promise<void>;
|
|||||||
|
|
||||||
export function OpenExamQuiz():Promise<void>;
|
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>;
|
export function SendChatMessage(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendWebcamFrame(arg1:string):Promise<void>;
|
export function SendWebcamFrame(arg1:string):Promise<void>;
|
||||||
|
|||||||
82
client/frontend/wailsjs/go/main/App.js
Normal file → Executable file
82
client/frontend/wailsjs/go/main/App.js
Normal file → Executable file
@@ -3,61 +3,113 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
export function CheckLoginStatus() {
|
export function CheckLoginStatus() {
|
||||||
return window['go']['main']['App']['CheckLoginStatus']();
|
return ObfuscatedCall(0, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClearChatUnread() {
|
export function ClearChatUnread() {
|
||||||
return window['go']['main']['App']['ClearChatUnread']();
|
return ObfuscatedCall(1, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearExamBrowserCache() {
|
||||||
|
return ObfuscatedCall(2, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadExamResource(arg1) {
|
||||||
|
return ObfuscatedCall(3, [arg1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetChatConversations() {
|
export function GetChatConversations() {
|
||||||
return window['go']['main']['App']['GetChatConversations']();
|
return ObfuscatedCall(4, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetChatMessages(arg1) {
|
export function GetChatMessages(arg1) {
|
||||||
return window['go']['main']['App']['GetChatMessages'](arg1);
|
return ObfuscatedCall(5, [arg1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetChatUnread() {
|
export function GetChatUnread() {
|
||||||
return window['go']['main']['App']['GetChatUnread']();
|
return ObfuscatedCall(6, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetExamPaperFiles() {
|
||||||
|
return ObfuscatedCall(7, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetExamPaperViewURL() {
|
||||||
|
return ObfuscatedCall(8, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetExamQuizViewURL() {
|
||||||
|
return ObfuscatedCall(9, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetStats() {
|
export function GetStats() {
|
||||||
return window['go']['main']['App']['GetStats']();
|
return ObfuscatedCall(10, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetStudentInfo() {
|
export function GetStudentInfo() {
|
||||||
return window['go']['main']['App']['GetStudentInfo']();
|
return ObfuscatedCall(11, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HandleBeforeClose() {
|
||||||
|
return ObfuscatedCall(12, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadExamViewFile(arg1) {
|
||||||
|
return ObfuscatedCall(13, [arg1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Logout() {
|
export function Logout() {
|
||||||
return window['go']['main']['App']['Logout']();
|
return ObfuscatedCall(14, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavigateToLogin() {
|
export function NavigateToLogin() {
|
||||||
return window['go']['main']['App']['NavigateToLogin']();
|
return ObfuscatedCall(15, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenExamPaper() {
|
export function OpenExamPaper() {
|
||||||
return window['go']['main']['App']['OpenExamPaper']();
|
return ObfuscatedCall(16, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenExamQuiz() {
|
export function OpenExamQuiz() {
|
||||||
return window['go']['main']['App']['OpenExamQuiz']();
|
return ObfuscatedCall(17, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenExamResource(arg1) {
|
||||||
|
return ObfuscatedCall(18, [arg1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenGitHub() {
|
||||||
|
return ObfuscatedCall(19, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenGoogleTranslate() {
|
||||||
|
return ObfuscatedCall(20, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenLocalBrowser() {
|
||||||
|
return ObfuscatedCall(21, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReloadExamPage() {
|
||||||
|
return ObfuscatedCall(22, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnToDashboard() {
|
||||||
|
return ObfuscatedCall(23, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendChatMessage(arg1) {
|
export function SendChatMessage(arg1) {
|
||||||
return window['go']['main']['App']['SendChatMessage'](arg1);
|
return ObfuscatedCall(24, [arg1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SendWebcamFrame(arg1) {
|
export function SendWebcamFrame(arg1) {
|
||||||
return window['go']['main']['App']['SendWebcamFrame'](arg1);
|
return ObfuscatedCall(25, [arg1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitExamWork() {
|
export function SubmitExamWork() {
|
||||||
return window['go']['main']['App']['SubmitExamWork']();
|
return ObfuscatedCall(26, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnlockChatAudio() {
|
export function UnlockChatAudio() {
|
||||||
return window['go']['main']['App']['UnlockChatAudio']();
|
return ObfuscatedCall(27, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,72 +2,11 @@ package blocker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
user32 = syscall.NewLazyDLL("user32.dll")
|
|
||||||
procEnumWindows = user32.NewProc("EnumWindows")
|
|
||||||
procIsWindowVisible = user32.NewProc("IsWindowVisible")
|
|
||||||
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
|
||||||
procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
|
|
||||||
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
|
||||||
procGetWindow = user32.NewProc("GetWindow")
|
|
||||||
procGetWindowLongW = user32.NewProc("GetWindowLongW")
|
|
||||||
|
|
||||||
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
|
|
||||||
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
|
||||||
|
|
||||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
GW_OWNER = 4
|
|
||||||
WS_EX_TOOLWINDOW = 0x00000080
|
|
||||||
DWMWA_CLOAKED = 14
|
|
||||||
)
|
|
||||||
|
|
||||||
func isRealGUIWindow(hwnd uintptr) bool {
|
|
||||||
// 1. Phải đang hiển thị
|
|
||||||
ret, _, _ := procIsWindowVisible.Call(hwnd)
|
|
||||||
if ret == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Không được có chủ sở hữu (phải là cửa sổ chính - top-level)
|
|
||||||
owner, _, _ := procGetWindow.Call(hwnd, GW_OWNER)
|
|
||||||
if owner != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Không phải là tool window (WS_EX_TOOLWINDOW)
|
|
||||||
gwlExStyle := int32(-20) // GWL_EXSTYLE = -20
|
|
||||||
style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(gwlExStyle))
|
|
||||||
if (style & WS_EX_TOOLWINDOW) != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Không bị cloaked bởi DWM (ví dụ: app UWP bị treo/chạy ngầm, màn hình ảo)
|
|
||||||
var cloaked uint32
|
|
||||||
hr, _, _ := procDwmGetWindowAttribute.Call(hwnd, DWMWA_CLOAKED, uintptr(unsafe.Pointer(&cloaked)), 4)
|
|
||||||
if hr == 0 && cloaked != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type WindowInfo struct {
|
|
||||||
PID uint32
|
|
||||||
Title string
|
|
||||||
ProcessName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Blocker struct {
|
type Blocker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
allowedKeywords []string
|
allowedKeywords []string
|
||||||
@@ -82,106 +21,6 @@ var Instance = &Blocker{
|
|||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
func getProcessMap() (map[uint32]string, error) {
|
|
||||||
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer syscall.CloseHandle(snapshot)
|
|
||||||
|
|
||||||
var pe syscall.ProcessEntry32
|
|
||||||
pe.Size = uint32(unsafe.Sizeof(pe))
|
|
||||||
|
|
||||||
err = syscall.Process32First(snapshot, &pe)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pm := make(map[uint32]string)
|
|
||||||
for {
|
|
||||||
name := syscall.UTF16ToString(pe.ExeFile[:])
|
|
||||||
pm[pe.ProcessID] = name
|
|
||||||
|
|
||||||
err = syscall.Process32Next(snapshot, &pe)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pm, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getWindowText(hwnd uintptr) string {
|
|
||||||
length, _, _ := procGetWindowTextLengthW.Call(hwnd)
|
|
||||||
if length == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
buf := make([]uint16, length+1)
|
|
||||||
procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), length+1)
|
|
||||||
return syscall.UTF16ToString(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
enumWindowsMutex sync.Mutex
|
|
||||||
enumWindowsList []WindowInfo
|
|
||||||
enumProcessMap map[uint32]string
|
|
||||||
)
|
|
||||||
|
|
||||||
var enumWindowsCallback = syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Printf("[BLOCKER] Callback panic recovered: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if !isRealGUIWindow(hwnd) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
title := getWindowText(hwnd)
|
|
||||||
if title == "" {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var pid uint32
|
|
||||||
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
|
|
||||||
|
|
||||||
procName := enumProcessMap[pid]
|
|
||||||
if procName == "" {
|
|
||||||
procName = "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
enumWindowsList = append(enumWindowsList, WindowInfo{
|
|
||||||
PID: pid,
|
|
||||||
Title: title,
|
|
||||||
ProcessName: procName,
|
|
||||||
})
|
|
||||||
|
|
||||||
return 1
|
|
||||||
})
|
|
||||||
|
|
||||||
func EnumerateGUIWindows() ([]WindowInfo, error) {
|
|
||||||
pMap, err := getProcessMap()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
enumWindowsMutex.Lock()
|
|
||||||
defer enumWindowsMutex.Unlock()
|
|
||||||
|
|
||||||
enumWindowsList = make([]WindowInfo, 0, 100)
|
|
||||||
enumProcessMap = pMap
|
|
||||||
|
|
||||||
procEnumWindows.Call(enumWindowsCallback, 0)
|
|
||||||
|
|
||||||
// Clean up map reference so GC can reclaim it
|
|
||||||
enumProcessMap = nil
|
|
||||||
|
|
||||||
// Copy to a new slice to return safely
|
|
||||||
res := make([]WindowInfo, len(enumWindowsList))
|
|
||||||
copy(res, enumWindowsList)
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseKeywordList(keywords string) []string {
|
func parseKeywordList(keywords string) []string {
|
||||||
keywords = strings.ReplaceAll(keywords, "\r\n", "\n")
|
keywords = strings.ReplaceAll(keywords, "\r\n", "\n")
|
||||||
parts := strings.FieldsFunc(keywords, func(r rune) bool {
|
parts := strings.FieldsFunc(keywords, func(r rune) bool {
|
||||||
@@ -236,90 +75,9 @@ func matchesAllowedKeyword(kw, pNameLower, wTitleLower string) bool {
|
|||||||
|
|
||||||
func (b *Blocker) SetKeywords(keywords string) {
|
func (b *Blocker) SetKeywords(keywords string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
b.allowedKeywords = parseKeywordList(keywords)
|
b.allowedKeywords = parseKeywordList(keywords)
|
||||||
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
var systemAllowed = map[string]bool{
|
|
||||||
"explorer.exe": true,
|
|
||||||
"taskmgr.exe": true,
|
|
||||||
"cmd.exe": true,
|
|
||||||
"powershell.exe": true,
|
|
||||||
"conhost.exe": true,
|
|
||||||
"client.exe": true, // App Wails của ta
|
|
||||||
"wails.exe": true,
|
|
||||||
"msedgewebview2.exe": true, // WebView2 runtime của Wails
|
|
||||||
"code.exe": true, // VSCode
|
|
||||||
"cursor.exe": true, // Cursor
|
|
||||||
"windsurf.exe": true, // Windsurf
|
|
||||||
"goland.exe": true, // GoLand
|
|
||||||
"goland64.exe": true, // GoLand
|
|
||||||
"idea64.exe": true, // IntelliJ IDEA
|
|
||||||
"clion64.exe": true, // CLion
|
|
||||||
"webstorm64.exe": true, // WebStorm
|
|
||||||
"pycharm64.exe": true, // PyCharm
|
|
||||||
"rider64.exe": true, // Rider
|
|
||||||
"studio64.exe": true, // Android Studio
|
|
||||||
"eclipse.exe": true, // Eclipse
|
|
||||||
"sublime_text.exe": true, // Sublime Text
|
|
||||||
"notepad++.exe": true, // Notepad++
|
|
||||||
"devenv.exe": true, // Visual Studio
|
|
||||||
"git-bash.exe": true, // Git Bash
|
|
||||||
"bash.exe": true, // Bash
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Blocker) checkAndKill() {
|
|
||||||
b.mu.Lock()
|
|
||||||
keywords := make([]string, len(b.allowedKeywords))
|
|
||||||
copy(keywords, b.allowedKeywords)
|
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
log.Printf("[BLOCKER] Keywords updated: %v", b.allowedKeywords)
|
||||||
// Nếu không cấu hình keyword thì không chặn gì cả
|
|
||||||
if len(keywords) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
windows, err := EnumerateGUIWindows()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, w := range windows {
|
|
||||||
pNameLower := strings.ToLower(w.ProcessName)
|
|
||||||
wTitleLower := strings.ToLower(w.Title)
|
|
||||||
|
|
||||||
// 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này
|
|
||||||
if w.PID == uint32(os.Getpid()) || systemAllowed[pNameLower] || strings.Contains(pNameLower, "antigravity") || strings.Contains(wTitleLower, "antigravity") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
|
|
||||||
allowed := false
|
|
||||||
for _, kw := range keywords {
|
|
||||||
if matchesAllowedKeyword(kw, pNameLower, wTitleLower) {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Nếu không nằm trong whitelist, tắt ứng dụng
|
|
||||||
if !allowed {
|
|
||||||
if b.OnBlocked != nil {
|
|
||||||
b.OnBlocked(w.ProcessName, w.Title)
|
|
||||||
}
|
|
||||||
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d, Title: %s)", w.ProcessName, w.PID, w.Title)
|
|
||||||
h, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, w.PID)
|
|
||||||
if err == nil {
|
|
||||||
errTerm := syscall.TerminateProcess(h, 0)
|
|
||||||
_ = syscall.CloseHandle(h)
|
|
||||||
if errTerm == nil && b.OnKill != nil {
|
|
||||||
b.OnKill(w.ProcessName, w.Title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Blocker) Start() {
|
func (b *Blocker) Start() {
|
||||||
|
|||||||
303
client/internal/blocker/blocker_darwin.go
Normal file
303
client/internal/blocker/blocker_darwin.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemAllowed = map[string]bool{
|
||||||
|
// ── Core macOS Desktop Infrastructure ─────────────────────────────────────
|
||||||
|
"finder": true, // macOS file manager / desktop
|
||||||
|
"dock": true, // macOS Dock — kill = dock disappears
|
||||||
|
"windowserver": true, // WindowServer — kill = instant logout
|
||||||
|
"loginwindow": true, // Login/session manager — kill = logout
|
||||||
|
"systemuiserver": true, // Menu bar icons (volume, wifi, battery, clock)
|
||||||
|
"controlcenter": true, // macOS Control Center (Monterey+)
|
||||||
|
"notificationcenter": true, // Notification Center
|
||||||
|
"spotlight": true, // Spotlight search
|
||||||
|
"launchpad": true,
|
||||||
|
"mission control": true,
|
||||||
|
"exposé": true,
|
||||||
|
"universalaccessd": true,
|
||||||
|
"accessibilityuiagent": true, // Accessibility helper
|
||||||
|
|
||||||
|
// ── Input Methods & Language (critical — kill = can't type) ───────────────
|
||||||
|
"inputmethodkit": true,
|
||||||
|
"ibus": true,
|
||||||
|
"hiragana kakomi input": true,
|
||||||
|
"kinput2": true,
|
||||||
|
"squirrel": true, // Rime input method
|
||||||
|
"scim": true,
|
||||||
|
"kotoeri": true, // Japanese IME
|
||||||
|
"pinyin - simplified": true, // macOS Chinese Pinyin
|
||||||
|
"zhuyin - traditional": true,
|
||||||
|
"vietnamese": true, // macOS built-in Vietnamese IME
|
||||||
|
"abc": true, // macOS ABC keyboard input
|
||||||
|
|
||||||
|
// ── Security / Keychain / Authentication ──────────────────────────────────
|
||||||
|
"securityagent": true, // macOS security agent — kill breaks sudo GUI, Keychain prompts
|
||||||
|
"keychain": true, // Keychain access
|
||||||
|
"keychainservicesagent": true,
|
||||||
|
"trustd": true,
|
||||||
|
"opendirectoryd": true,
|
||||||
|
"authorizationhost": true, // Authorization host — UAC equivalent
|
||||||
|
"securityd": true,
|
||||||
|
"coreauthenticationd": true,
|
||||||
|
"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,
|
||||||
|
"audio midi setup": true,
|
||||||
|
"noiseremoval": true,
|
||||||
|
|
||||||
|
// ── Networking / VPN ──────────────────────────────────────────────────────
|
||||||
|
"networkd": true,
|
||||||
|
"nesessionmanager": true, // Network Extension — kill drops VPN
|
||||||
|
"scutil": true,
|
||||||
|
"configd": true,
|
||||||
|
"mDNSResponder": true, // Bonjour DNS
|
||||||
|
|
||||||
|
// ── Spotlight / File Indexing ──────────────────────────────────────────────
|
||||||
|
"mds": true, // Spotlight metadata server
|
||||||
|
"mds_stores": true,
|
||||||
|
"mdworker": true, // prefix match covers mdworker_shared
|
||||||
|
"mdworker_shared": true,
|
||||||
|
|
||||||
|
// ── iCloud / Apple Services ───────────────────────────────────────────────
|
||||||
|
"bird": true, // iCloud Drive daemon
|
||||||
|
"cloudd": true,
|
||||||
|
"com.apple.icloud": true, // prefix
|
||||||
|
"cloudphotod": true,
|
||||||
|
"nsurlsessiond": true,
|
||||||
|
|
||||||
|
// ── System Preferences / Settings ─────────────────────────────────────────
|
||||||
|
"system preferences": true, // macOS System Preferences (pre-Ventura)
|
||||||
|
"system settings": true, // macOS System Settings (Ventura+)
|
||||||
|
"software update": true,
|
||||||
|
"app store": true,
|
||||||
|
|
||||||
|
// ── Screen / Display ──────────────────────────────────────────────────────
|
||||||
|
"screensaver engine": true, // Screensaver
|
||||||
|
"com.apple.screensaver": true,
|
||||||
|
"colorsyncd": true,
|
||||||
|
"colorsync utility": true,
|
||||||
|
"nightshift": true,
|
||||||
|
"display menu": true,
|
||||||
|
|
||||||
|
// ── Clipboard / Pasteboard ────────────────────────────────────────────────
|
||||||
|
"pboard": true, // Pasteboard daemon — kill breaks copy/paste
|
||||||
|
|
||||||
|
// ── Printing ─────────────────────────────────────────────────────────────
|
||||||
|
"printingproxy": true,
|
||||||
|
"cupsd": true,
|
||||||
|
|
||||||
|
// ── Crash Reporting / Diagnostics ─────────────────────────────────────────
|
||||||
|
"crashreporter": true,
|
||||||
|
"diagnosticsd": true,
|
||||||
|
"spindump": true,
|
||||||
|
"reportmemoryexception": true,
|
||||||
|
|
||||||
|
// ── Webkit / App subprocesses ─────────────────────────────────────────────
|
||||||
|
"webkit": true, // prefix
|
||||||
|
"com.apple.webkit": true, // prefix
|
||||||
|
"com.apple.webkit.networking": true,
|
||||||
|
|
||||||
|
// ── Remote support ────────────────────────────────────────────────────────
|
||||||
|
"applescriptkit": true,
|
||||||
|
"applescript runner": true,
|
||||||
|
"rustdesk": true,
|
||||||
|
"anydesk": true,
|
||||||
|
"teamviewer": true,
|
||||||
|
"screen sharing": true,
|
||||||
|
"screensharingd": true, // macOS Screen Sharing
|
||||||
|
|
||||||
|
// ── Terminals ─────────────────────────────────────────────────────────────
|
||||||
|
"terminal": true, // macOS Terminal
|
||||||
|
"iterm": true,
|
||||||
|
"iterm2": true,
|
||||||
|
"wezterm": true,
|
||||||
|
"kitty": true,
|
||||||
|
"alacritty": true,
|
||||||
|
"hyper": true,
|
||||||
|
|
||||||
|
// ── Shells ────────────────────────────────────────────────────────────────
|
||||||
|
"bash": true,
|
||||||
|
"zsh": true,
|
||||||
|
"sh": true,
|
||||||
|
"fish": true,
|
||||||
|
|
||||||
|
// ── AppleScript / Automation ──────────────────────────────────────────────
|
||||||
|
"system events": true, // AppleScript System Events (used by our blocker itself)
|
||||||
|
"osascript": true, // AppleScript runner (used by our getVisibleProcesses)
|
||||||
|
|
||||||
|
// ── Git & Credential Helpers ──────────────────────────────────────────────
|
||||||
|
"git": true,
|
||||||
|
"git-credential-manager": true,
|
||||||
|
"git-credential-osxkeychain": true,
|
||||||
|
"github desktop": true,
|
||||||
|
"sourcetree": true,
|
||||||
|
"fork": true,
|
||||||
|
|
||||||
|
// ── Docker ───────────────────────────────────────────────────────────────
|
||||||
|
"docker": true,
|
||||||
|
"docker desktop": true,
|
||||||
|
"com.docker": true, // prefix
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
"cursor": true,
|
||||||
|
"windsurf": true,
|
||||||
|
"goland": true,
|
||||||
|
"idea": true,
|
||||||
|
"clion": true,
|
||||||
|
"webstorm": true,
|
||||||
|
"pycharm": true,
|
||||||
|
"rider": true,
|
||||||
|
"studio": true, // Android Studio
|
||||||
|
"eclipse": true,
|
||||||
|
"sublime text": true,
|
||||||
|
}
|
||||||
|
type ProcessInfo struct {
|
||||||
|
Name string
|
||||||
|
BundleID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVisibleProcesses() (map[uint32]ProcessInfo, error) {
|
||||||
|
script := `tell application "System Events"
|
||||||
|
set out to ""
|
||||||
|
set procList to every process whose visible is true
|
||||||
|
repeat with p in procList
|
||||||
|
try
|
||||||
|
set nameStr to name of p
|
||||||
|
set pidVal to unix id of p
|
||||||
|
set bid to bundle identifier of p
|
||||||
|
if bid is missing value then
|
||||||
|
set bid to ""
|
||||||
|
end if
|
||||||
|
set out to out & nameStr & "|" & pidVal & "|" & bid & "\n"
|
||||||
|
on error
|
||||||
|
-- ignore
|
||||||
|
end try
|
||||||
|
end repeat
|
||||||
|
return out
|
||||||
|
end tell`
|
||||||
|
cmd := exec.Command("osascript", "-e", script)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
procs := make(map[uint32]ProcessInfo)
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pName := parts[0]
|
||||||
|
pIdStr := parts[1]
|
||||||
|
bundleID := ""
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
bundleID = parts[2]
|
||||||
|
}
|
||||||
|
var pid uint32
|
||||||
|
if _, err := fmt.Sscanf(pIdStr, "%d", &pid); err == nil {
|
||||||
|
procs[pid] = ProcessInfo{
|
||||||
|
Name: pName,
|
||||||
|
BundleID: bundleID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return procs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {
|
||||||
|
b.mu.Lock()
|
||||||
|
keywords := make([]string, len(b.allowedKeywords))
|
||||||
|
copy(keywords, b.allowedKeywords)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentExec := ""
|
||||||
|
if execPath, err := os.Executable(); err == nil {
|
||||||
|
currentExec = strings.ToLower(filepath.Base(execPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
procs, err := getVisibleProcesses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[BLOCKER] Failed to get visible processes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myPid := uint32(os.Getpid())
|
||||||
|
for pid, info := range procs {
|
||||||
|
pNameLower := strings.ToLower(info.Name)
|
||||||
|
|
||||||
|
// 1. Always allow our app, system/critical developer tools, or agent helpers
|
||||||
|
if pid == myPid || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if the process name contains any allowed keywords
|
||||||
|
allowed := false
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if matchesAllowedKeyword(kw, pNameLower, pNameLower) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If not allowed, kill the application
|
||||||
|
if !allowed {
|
||||||
|
if b.OnBlocked != nil {
|
||||||
|
b.OnBlocked(info.Name, info.Name)
|
||||||
|
}
|
||||||
|
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d)", info.Name, pid)
|
||||||
|
proc, err := os.FindProcess(int(pid))
|
||||||
|
if err == nil {
|
||||||
|
errKill := proc.Kill()
|
||||||
|
if errKill == nil && b.OnKill != nil {
|
||||||
|
b.OnKill(info.Name, info.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
403
client/internal/blocker/blocker_linux.go
Normal file
403
client/internal/blocker/blocker_linux.go
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemAllowed = map[string]bool{
|
||||||
|
// ── Display servers / Compositors ─────────────────────────────────────────
|
||||||
|
"gnome-shell": true,
|
||||||
|
"mutter": true,
|
||||||
|
"mutter-x11-fram": true, // prefix match too
|
||||||
|
"xwayland": true,
|
||||||
|
"xorg": true,
|
||||||
|
"Xorg": true,
|
||||||
|
"x11": true,
|
||||||
|
"kwin_wayland": true, // KDE compositor
|
||||||
|
"kwin_x11": true,
|
||||||
|
"plasmashell": true, // KDE Plasma shell
|
||||||
|
"hyprland": true, // Hyprland Wayland compositor
|
||||||
|
"sway": true, // Sway compositor
|
||||||
|
"wayfire": true, // Wayfire compositor
|
||||||
|
"river": true, // River compositor
|
||||||
|
"labwc": true, // LabWC compositor
|
||||||
|
"openbox": true,
|
||||||
|
"i3": true,
|
||||||
|
"i3bar": true,
|
||||||
|
"awesome": true,
|
||||||
|
"bspwm": true,
|
||||||
|
"xfwm4": true,
|
||||||
|
"marco": true, // MATE wm
|
||||||
|
"compiz": true,
|
||||||
|
"picom": true,
|
||||||
|
"compton": true,
|
||||||
|
|
||||||
|
// ── Wayland session tools ─────────────────────────────────────────────────
|
||||||
|
"wl-paste": true,
|
||||||
|
"wl-copy": true,
|
||||||
|
"wlr-randr": true,
|
||||||
|
"kanshi": true, // output manager
|
||||||
|
"wlsunset": true,
|
||||||
|
"gammastep": true,
|
||||||
|
"swaylock": true,
|
||||||
|
"swayidle": true,
|
||||||
|
"swaybg": true,
|
||||||
|
"swaync": true, // SwayNotificationCenter
|
||||||
|
"waybar": true, // Wayland statusbar
|
||||||
|
"eww": true, // ElKowar's wacky widgets
|
||||||
|
"ags": true, // Aylur's GTK Shell
|
||||||
|
"rofi": true, // app launcher (used in many WMs)
|
||||||
|
"wofi": true, // Wayland rofi
|
||||||
|
"fuzzel": true, // Wayland launcher
|
||||||
|
"tofi": true,
|
||||||
|
"dmenu": true,
|
||||||
|
"bemenu": true,
|
||||||
|
|
||||||
|
// ── Session / Login managers ──────────────────────────────────────────────
|
||||||
|
"gdm": true,
|
||||||
|
"gdm3": true,
|
||||||
|
"sddm": true,
|
||||||
|
"lightdm": true,
|
||||||
|
"lxdm": true,
|
||||||
|
"slim": true,
|
||||||
|
"greetd": true,
|
||||||
|
"gnome-session": true,
|
||||||
|
"gnome-session-bi": true, // prefix matches binary
|
||||||
|
"lxsession": true,
|
||||||
|
"startx": true,
|
||||||
|
"xinit": true,
|
||||||
|
|
||||||
|
// ── Terminals ─────────────────────────────────────────────────────────────
|
||||||
|
"xterm": true,
|
||||||
|
"gnome-terminal": true,
|
||||||
|
"gnome-terminal-": true, // prefix for server process
|
||||||
|
"ptyxis": true,
|
||||||
|
"konsole": true,
|
||||||
|
"kitty": true,
|
||||||
|
"alacritty": true,
|
||||||
|
"wezterm": true,
|
||||||
|
"wezterm-gui": true,
|
||||||
|
"foot": true,
|
||||||
|
"xfce4-terminal": true,
|
||||||
|
"tilix": true,
|
||||||
|
"terminator": true,
|
||||||
|
"urxvt": true,
|
||||||
|
"rxvt": true,
|
||||||
|
"sakura": true,
|
||||||
|
"st": true,
|
||||||
|
|
||||||
|
// ── Shells ────────────────────────────────────────────────────────────────
|
||||||
|
"bash": true,
|
||||||
|
"zsh": true,
|
||||||
|
"sh": true,
|
||||||
|
"fish": true,
|
||||||
|
"dash": true,
|
||||||
|
"ksh": true,
|
||||||
|
|
||||||
|
// ── Input methods (critical — killing these breaks Vietnamese typing) ─────
|
||||||
|
"ibus-daemon": true,
|
||||||
|
"ibus-x11": true,
|
||||||
|
"ibus-": true, // prefix: ibus-extension-, ibus-portal, ibus-engine-...
|
||||||
|
"fcitx5": true,
|
||||||
|
"fcitx": true,
|
||||||
|
"fcitx-": true, // prefix
|
||||||
|
"uim": true,
|
||||||
|
"scim": true,
|
||||||
|
"sogou-qimpanel": true,
|
||||||
|
"gcin": true,
|
||||||
|
"kimpanel": true,
|
||||||
|
|
||||||
|
// ── GNOME core services ───────────────────────────────────────────────────
|
||||||
|
"gjs": true,
|
||||||
|
"gsd-": true, // prefix: gsd-keyboard, gsd-media-keys, gsd-power, gsd-color...
|
||||||
|
"goa-daemon": true,
|
||||||
|
"goa-identity-ser": true,
|
||||||
|
"evolution-": true, // prefix: evolution-calendar, evolution-addressbook...
|
||||||
|
"gnome-keyring-d": true,
|
||||||
|
"gnome-keyring": true,
|
||||||
|
"polkit-gnome-au": true,
|
||||||
|
"polkitd": true,
|
||||||
|
"gnome-settings-d": true,
|
||||||
|
"gnome-initial-se": true,
|
||||||
|
"gnome-control-ce": true,
|
||||||
|
"gvfsd": true,
|
||||||
|
"gvfsd-": true, // prefix
|
||||||
|
"tracker-miner-": true, // prefix
|
||||||
|
"tracker3": true,
|
||||||
|
"zeitgeist": true,
|
||||||
|
"zeitgeist-": true,
|
||||||
|
"accounts-daemon": true,
|
||||||
|
"colord": true,
|
||||||
|
"power-profiles-": true,
|
||||||
|
"fprintd": true,
|
||||||
|
"fwupd": true,
|
||||||
|
"udisksd": true,
|
||||||
|
"upowerd": true,
|
||||||
|
"packagekitd": true,
|
||||||
|
"nm-dispatcher": true,
|
||||||
|
|
||||||
|
// ── D-Bus / XDG / AT-SPI ─────────────────────────────────────────────────
|
||||||
|
"xdg-": true, // prefix: xdg-desktop-portal, xdg-permission-store...
|
||||||
|
"at-spi": true, // prefix
|
||||||
|
"at-spi-bus-laun": true,
|
||||||
|
"at-spi2-registr": true,
|
||||||
|
"dbus-daemon": true,
|
||||||
|
"dbus-launch": true,
|
||||||
|
|
||||||
|
// ── System services (often have GTK tray icons) ───────────────────────────
|
||||||
|
"systemd": true, // prefix match handles systemd-*
|
||||||
|
"snapd-": true, // prefix
|
||||||
|
"snapd": true,
|
||||||
|
|
||||||
|
// ── Network/Bluetooth tray (critical for connectivity UI) ─────────────────
|
||||||
|
"nm-applet": true, // NetworkManager tray — if killed, students lose wifi UI
|
||||||
|
"nm-tray": true,
|
||||||
|
"network-manager-": true, // prefix
|
||||||
|
"blueman-applet": true, // Bluetooth tray — kills BT management
|
||||||
|
"blueman-tray": true,
|
||||||
|
"blueman-manager": true,
|
||||||
|
"kdeconnectd": true, // KDE Connect daemon
|
||||||
|
"kdeconnect-indi": true, // KDE Connect indicator
|
||||||
|
|
||||||
|
// ── Notification daemons ──────────────────────────────────────────────────
|
||||||
|
"dunst": true,
|
||||||
|
"mako": true,
|
||||||
|
"notify-osd": true,
|
||||||
|
"xfce4-notifyd": true,
|
||||||
|
"fnott": true,
|
||||||
|
|
||||||
|
// ── Polkit authentication agents ─────────────────────────────────────────
|
||||||
|
"lxqt-policykit-": true,
|
||||||
|
"xfce-polkit": true,
|
||||||
|
"mate-polkit": true,
|
||||||
|
"pkttyagent": true,
|
||||||
|
|
||||||
|
// ── Clipboard managers (killing breaks copy/paste) ────────────────────────
|
||||||
|
"copyq": true,
|
||||||
|
"clipit": true,
|
||||||
|
"xclip": true,
|
||||||
|
"xsel": true,
|
||||||
|
"clipman": true,
|
||||||
|
"greenclip": true,
|
||||||
|
|
||||||
|
// ── GTK/GNOME image helpers ───────────────────────────────────────────────
|
||||||
|
"glycin": true,
|
||||||
|
"zenity": true,
|
||||||
|
"yad": true,
|
||||||
|
"kdialog": true,
|
||||||
|
|
||||||
|
// ── Screensaver / Lock ────────────────────────────────────────────────────
|
||||||
|
"gnome-screensave": true,
|
||||||
|
"xscreensaver": true,
|
||||||
|
"xlock": true,
|
||||||
|
"i3lock": true,
|
||||||
|
|
||||||
|
// ── WebKit subprocesses (used by many GTK apps) ───────────────────────────
|
||||||
|
"webkit": true, // prefix
|
||||||
|
"webkit2gtk": true,
|
||||||
|
"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,
|
||||||
|
"anydesk": true,
|
||||||
|
"teamviewer": true,
|
||||||
|
"remmina": true,
|
||||||
|
|
||||||
|
// ── Our app + dev tools ───────────────────────────────────────────────────
|
||||||
|
"wails": true,
|
||||||
|
"code": true,
|
||||||
|
"cursor": true,
|
||||||
|
"windsurf": true,
|
||||||
|
"goland": true,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSystemAllowed(name string) bool {
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
if systemAllowed[name] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for pattern := range systemAllowed {
|
||||||
|
if strings.HasPrefix(name, pattern) && pattern != name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPPID(pid uint32) uint32 {
|
||||||
|
statusBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(statusBytes), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "PPid:") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
if ppid, err := strconv.ParseUint(parts[1], 10, 32); err == nil {
|
||||||
|
return uint32(ppid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDescendantOf(pid, targetPid uint32) bool {
|
||||||
|
curr := pid
|
||||||
|
for i := 0; i < 10; i++ { // limits lookup to 10 ancestor levels
|
||||||
|
ppid := getPPID(curr)
|
||||||
|
if ppid == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ppid == targetPid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
curr = ppid
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessInfo struct {
|
||||||
|
PID uint32
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVisibleProcesses() (map[uint32]ProcessInfo, error) {
|
||||||
|
files, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
procs := make(map[uint32]ProcessInfo)
|
||||||
|
for _, f := range files {
|
||||||
|
if !f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pid, err := strconv.ParseUint(f.Name(), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mapsPath := fmt.Sprintf("/proc/%d/maps", pid)
|
||||||
|
mapsBytes, err := os.ReadFile(mapsPath)
|
||||||
|
if err != nil {
|
||||||
|
// Skip processes we don't own (permission denied)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mapsStr := string(mapsBytes)
|
||||||
|
isGUI := strings.Contains(mapsStr, "libgtk") ||
|
||||||
|
strings.Contains(mapsStr, "libQt") ||
|
||||||
|
strings.Contains(mapsStr, "libX11") ||
|
||||||
|
strings.Contains(mapsStr, "libwayland-client")
|
||||||
|
|
||||||
|
if !isGUI {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read process name from /proc/PID/comm
|
||||||
|
commBytes, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
procName := strings.TrimSpace(string(commBytes))
|
||||||
|
|
||||||
|
if procName != "" {
|
||||||
|
procs[uint32(pid)] = ProcessInfo{
|
||||||
|
PID: uint32(pid),
|
||||||
|
Name: procName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return procs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {
|
||||||
|
b.mu.Lock()
|
||||||
|
keywords := make([]string, len(b.allowedKeywords))
|
||||||
|
copy(keywords, b.allowedKeywords)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentExec := ""
|
||||||
|
if execPath, err := os.Executable(); err == nil {
|
||||||
|
currentExec = strings.ToLower(filepath.Base(execPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
procs, err := getVisibleProcesses()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[BLOCKER] Failed to get visible processes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myPid := uint32(os.Getpid())
|
||||||
|
for pid, info := range procs {
|
||||||
|
pNameLower := strings.ToLower(info.Name)
|
||||||
|
|
||||||
|
// 1. Always allow our app, our sub-processes, system/critical developer tools, or agent helpers
|
||||||
|
isOurSubprocess := pid == myPid || isDescendantOf(pid, myPid)
|
||||||
|
if isOurSubprocess || (currentExec != "" && pNameLower == currentExec) || isSystemAllowed(pNameLower) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if the process name contains any allowed keywords
|
||||||
|
allowed := false
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if matchesAllowedKeyword(kw, pNameLower, pNameLower) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If not allowed, kill the application
|
||||||
|
if !allowed {
|
||||||
|
if b.OnBlocked != nil {
|
||||||
|
b.OnBlocked(info.Name, info.Name)
|
||||||
|
}
|
||||||
|
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d)", info.Name, pid)
|
||||||
|
proc, err := os.FindProcess(int(pid))
|
||||||
|
if err == nil {
|
||||||
|
errKill := proc.Kill()
|
||||||
|
if errKill == nil && b.OnKill != nil {
|
||||||
|
b.OnKill(info.Name, info.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
client/internal/blocker/blocker_other.go
Normal file
5
client/internal/blocker/blocker_other.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {}
|
||||||
428
client/internal/blocker/blocker_windows.go
Normal file
428
client/internal/blocker/blocker_windows.go
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package blocker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = syscall.NewLazyDLL("user32.dll")
|
||||||
|
procEnumWindows = user32.NewProc("EnumWindows")
|
||||||
|
procIsWindowVisible = user32.NewProc("IsWindowVisible")
|
||||||
|
procGetWindowTextW = user32.NewProc("GetWindowTextW")
|
||||||
|
procGetWindowTextLengthW = user32.NewProc("GetWindowTextLengthW")
|
||||||
|
procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId")
|
||||||
|
procGetWindow = user32.NewProc("GetWindow")
|
||||||
|
procGetWindowLongW = user32.NewProc("GetWindowLongW")
|
||||||
|
procGetAncestor = user32.NewProc("GetAncestor")
|
||||||
|
|
||||||
|
dwmapi = syscall.NewLazyDLL("dwmapi.dll")
|
||||||
|
procDwmGetWindowAttribute = dwmapi.NewProc("DwmGetWindowAttribute")
|
||||||
|
|
||||||
|
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
GW_OWNER = 4
|
||||||
|
WS_EX_TOOLWINDOW = 0x00000080
|
||||||
|
DWMWA_CLOAKED = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
func isRealGUIWindow(hwnd uintptr) bool {
|
||||||
|
// 1. Phải đang hiển thị
|
||||||
|
ret, _, _ := procIsWindowVisible.Call(hwnd)
|
||||||
|
if ret == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Không được có chủ sở hữu (phải là cửa sổ chính - top-level)
|
||||||
|
owner, _, _ := procGetWindow.Call(hwnd, GW_OWNER)
|
||||||
|
if owner != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Không phải là tool window (WS_EX_TOOLWINDOW)
|
||||||
|
gwlExStyle := int32(-20) // GWL_EXSTYLE = -20
|
||||||
|
style, _, _ := procGetWindowLongW.Call(hwnd, uintptr(gwlExStyle))
|
||||||
|
if (style & WS_EX_TOOLWINDOW) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Không bị cloaked bởi DWM (ví dụ: app UWP bị treo/chạy ngầm, màn hình ảo)
|
||||||
|
var cloaked uint32
|
||||||
|
hr, _, _ := procDwmGetWindowAttribute.Call(hwnd, DWMWA_CLOAKED, uintptr(unsafe.Pointer(&cloaked)), 4)
|
||||||
|
if hr == 0 && cloaked != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowInfo struct {
|
||||||
|
PID uint32
|
||||||
|
Title string
|
||||||
|
ProcessName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProcessMap() (map[uint32]string, map[uint32]uint32, error) {
|
||||||
|
snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer syscall.CloseHandle(snapshot)
|
||||||
|
|
||||||
|
var pe syscall.ProcessEntry32
|
||||||
|
pe.Size = uint32(unsafe.Sizeof(pe))
|
||||||
|
|
||||||
|
err = syscall.Process32First(snapshot, &pe)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pm := make(map[uint32]string)
|
||||||
|
parents := make(map[uint32]uint32)
|
||||||
|
for {
|
||||||
|
name := syscall.UTF16ToString(pe.ExeFile[:])
|
||||||
|
pm[pe.ProcessID] = name
|
||||||
|
parents[pe.ProcessID] = pe.ParentProcessID
|
||||||
|
|
||||||
|
err = syscall.Process32Next(snapshot, &pe)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pm, parents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWindowText(hwnd uintptr) string {
|
||||||
|
length, _, _ := procGetWindowTextLengthW.Call(hwnd)
|
||||||
|
if length == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
buf := make([]uint16, length+1)
|
||||||
|
procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), length+1)
|
||||||
|
return syscall.UTF16ToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
enumWindowsMutex sync.Mutex
|
||||||
|
enumWindowsList []WindowInfo
|
||||||
|
enumProcessMap map[uint32]string
|
||||||
|
enumParentMap map[uint32]uint32
|
||||||
|
)
|
||||||
|
|
||||||
|
var enumWindowsCallback = syscall.NewCallback(func(hwnd uintptr, lParam uintptr) uintptr {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[BLOCKER] Callback panic recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !isRealGUIWindow(hwnd) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kiểm tra xem chủ sở hữu gốc (root owner) của cửa sổ này có thuộc về app ta hay không
|
||||||
|
rootHwnd, _, _ := procGetAncestor.Call(hwnd, 3) // GA_ROOTOWNER = 3
|
||||||
|
if rootHwnd != 0 {
|
||||||
|
var rootPid uint32
|
||||||
|
procGetWindowThreadProcessId.Call(rootHwnd, uintptr(unsafe.Pointer(&rootPid)))
|
||||||
|
if rootPid == uint32(os.Getpid()) {
|
||||||
|
return 1 // Cửa sổ thuộc về WebView2 / app của ta, bỏ qua không quét
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := getWindowText(hwnd)
|
||||||
|
if title == "" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var pid uint32
|
||||||
|
procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid)))
|
||||||
|
|
||||||
|
procName := enumProcessMap[pid]
|
||||||
|
if procName == "" {
|
||||||
|
procName = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
enumWindowsList = append(enumWindowsList, WindowInfo{
|
||||||
|
PID: pid,
|
||||||
|
Title: title,
|
||||||
|
ProcessName: procName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
|
||||||
|
func EnumerateGUIWindows() ([]WindowInfo, map[uint32]uint32, error) {
|
||||||
|
pMap, parentMap, err := getProcessMap()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enumWindowsMutex.Lock()
|
||||||
|
defer enumWindowsMutex.Unlock()
|
||||||
|
|
||||||
|
enumWindowsList = make([]WindowInfo, 0, 100)
|
||||||
|
enumProcessMap = pMap
|
||||||
|
enumParentMap = parentMap
|
||||||
|
|
||||||
|
procEnumWindows.Call(enumWindowsCallback, 0)
|
||||||
|
|
||||||
|
// Clean up map reference so GC can reclaim it
|
||||||
|
enumProcessMap = nil
|
||||||
|
enumParentMap = nil
|
||||||
|
|
||||||
|
// Copy to a new slice to return safely
|
||||||
|
res := make([]WindowInfo, len(enumWindowsList))
|
||||||
|
copy(res, enumWindowsList)
|
||||||
|
return res, parentMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemAllowed = map[string]bool{
|
||||||
|
// ── Windows Shell & Explorer ───────────────────────────────────────────────
|
||||||
|
"explorer.exe": true, // Windows Explorer / desktop shell
|
||||||
|
"shellexperiencehost.exe": true, // Start menu, Action Center shell
|
||||||
|
"startmenuexperiencehost.exe": true, // Start menu host
|
||||||
|
"searchhost.exe": true, // Windows Search UI
|
||||||
|
"searchapp.exe": true, // Windows Search (older)
|
||||||
|
"searchindexer.exe": true,
|
||||||
|
"sihost.exe": true, // Shell Infrastructure Host (taskbar, notification icons)
|
||||||
|
"taskhostw.exe": true, // Task Host Window
|
||||||
|
"taskbar.exe": true,
|
||||||
|
"lockapp.exe": true, // Lock screen
|
||||||
|
"logonui.exe": true, // Login/Logon UI
|
||||||
|
"winlogon.exe": true, // Windows Logon Process
|
||||||
|
"userinit.exe": true,
|
||||||
|
"dwm.exe": true, // Desktop Window Manager — kill = black screen
|
||||||
|
"csrss.exe": true, // Client Server Runtime — kill = BSOD
|
||||||
|
|
||||||
|
// ── UWP / Modern App Infrastructure ───────────────────────────────────────
|
||||||
|
"applicationframehost.exe": true, // Host for ALL UWP apps (Camera, Calculator, Photos...)
|
||||||
|
"runtimebroker.exe": true, // UWP permission broker
|
||||||
|
"backgroundtaskhost.exe": true, // UWP background tasks
|
||||||
|
"wwahost.exe": true, // Web app host
|
||||||
|
"microsoftedgecp.exe": true, // Edge content process (older)
|
||||||
|
"textinputhost.exe": true, // Touch keyboard / handwriting panel
|
||||||
|
|
||||||
|
// ── Input Methods & Language ───────────────────────────────────────────────
|
||||||
|
"ctfmon.exe": true, // Collaborative Translation Framework — input method manager
|
||||||
|
"chsime.exe": true, // Chinese IME
|
||||||
|
"imetip.exe": true, // IME tip
|
||||||
|
"imecmnt.exe": true,
|
||||||
|
"googlepinyin.exe": true,
|
||||||
|
"baidu.exe": true, // Baidu IME
|
||||||
|
"openkey.exe": true, // OpenKey Vietnamese IME
|
||||||
|
"openkey64.exe": true,
|
||||||
|
"gotiengviet.exe": true, // GoTiengViet
|
||||||
|
"unikeyvnt.exe": true, // Unikey
|
||||||
|
"unikey.exe": true,
|
||||||
|
|
||||||
|
// ── Security / Credential / UAC ───────────────────────────────────────────
|
||||||
|
"consent.exe": true, // UAC consent dialog — kill breaks all elevation
|
||||||
|
"credentialuibroker.exe": true, // Credential UI
|
||||||
|
"smartscreen.exe": true, // Windows SmartScreen
|
||||||
|
"securityhealthsystray.exe": true, // Windows Security tray icon
|
||||||
|
"securityhealthservice.exe": true,
|
||||||
|
"wscsvc.exe": true,
|
||||||
|
"msseces.exe": true, // Microsoft Security Essentials tray
|
||||||
|
"msmpeng.exe": true, // Windows Defender engine (has no GUI but safety)
|
||||||
|
"nisSrv.exe": true,
|
||||||
|
"antimalware service executable": true, // Window title of Defender
|
||||||
|
|
||||||
|
// ── Notifications & Action Center ─────────────────────────────────────────
|
||||||
|
"notificationplatformcontroller": true,
|
||||||
|
|
||||||
|
// ── System Tray / Taskbar helpers ─────────────────────────────────────────
|
||||||
|
"systemsettings.exe": true, // Windows Settings
|
||||||
|
"settingssynchostservice.exe": true,
|
||||||
|
"settingssynchost.exe": true,
|
||||||
|
"regsvc.exe": true,
|
||||||
|
"spoolsv.exe": true,
|
||||||
|
"tabtip.exe": true, // Touch keyboard
|
||||||
|
"tabtip32.exe": true,
|
||||||
|
"onedrive.exe": true, // OneDrive tray (common, safe to keep)
|
||||||
|
"onedriveupdater.exe": true,
|
||||||
|
|
||||||
|
// ── Accessibility ─────────────────────────────────────────────────────────
|
||||||
|
"narrator.exe": true,
|
||||||
|
"magnify.exe": true,
|
||||||
|
"osk.exe": true, // On-Screen Keyboard
|
||||||
|
|
||||||
|
// ── Audio ─────────────────────────────────────────────────────────────────
|
||||||
|
"audiodg.exe": true, // Windows Audio Device Graph — kill = no sound
|
||||||
|
"sndvol.exe": true, // Volume mixer
|
||||||
|
"cmediaaudiocontrolpanel.exe": true, // C-Media audio panel
|
||||||
|
|
||||||
|
// ── Windows Update / Store ─────────────────────────────────────────────────
|
||||||
|
"wuauclt.exe": true, // Windows Update
|
||||||
|
"musnotifyicon.exe": true, // Update tray notification
|
||||||
|
"windowsstore.exe": true,
|
||||||
|
"winstore.app.exe": true,
|
||||||
|
|
||||||
|
// ── Drivers / Hardware UI ──────────────────────────────────────────────────
|
||||||
|
"nvdisplay.container.exe": true, // NVIDIA display container
|
||||||
|
"nvcontainer.exe": true,
|
||||||
|
"nvinject.exe": true,
|
||||||
|
"nvtelemetrycontainer.exe": true,
|
||||||
|
"nvvsvc.exe": true,
|
||||||
|
"nvspcaps64.exe": true,
|
||||||
|
"geforce experience.exe": true,
|
||||||
|
"radeonsoftware.exe": true, // AMD Radeon Software
|
||||||
|
"amddvr.exe": true,
|
||||||
|
"igfxtray.exe": true, // Intel graphics tray
|
||||||
|
"igfxem.exe": true,
|
||||||
|
"igfxhk.exe": true,
|
||||||
|
"hkcmd.exe": true,
|
||||||
|
"atk hub.exe": true, // ASUS ATK
|
||||||
|
"atkex.exe": true,
|
||||||
|
"asusoptimization.exe": true,
|
||||||
|
|
||||||
|
// ── 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 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,
|
||||||
|
"resmon.exe": true, // Resource Monitor
|
||||||
|
"perfmon.exe": true,
|
||||||
|
"mmc.exe": true, // Management Console
|
||||||
|
|
||||||
|
// ── Terminal / Shell ───────────────────────────────────────────────────────
|
||||||
|
"cmd.exe": true,
|
||||||
|
"powershell.exe": true,
|
||||||
|
"pwsh.exe": true, // PowerShell Core
|
||||||
|
"conhost.exe": true, // Console Host
|
||||||
|
"windowsterminal.exe": true, // Windows Terminal
|
||||||
|
"wt.exe": true,
|
||||||
|
"bash.exe": true,
|
||||||
|
"git-bash.exe": true,
|
||||||
|
"mintty.exe": true, // Git Bash window
|
||||||
|
"wsl.exe": true,
|
||||||
|
"wslhost.exe": true,
|
||||||
|
|
||||||
|
// ── 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)
|
||||||
|
"code.exe": true,
|
||||||
|
"cursor.exe": true,
|
||||||
|
"windsurf.exe": true,
|
||||||
|
"goland.exe": true,
|
||||||
|
"goland64.exe": true,
|
||||||
|
"idea64.exe": true,
|
||||||
|
"clion64.exe": true,
|
||||||
|
"webstorm64.exe": true,
|
||||||
|
"pycharm64.exe": true,
|
||||||
|
"rider64.exe": true,
|
||||||
|
"studio64.exe": true,
|
||||||
|
"eclipse.exe": true,
|
||||||
|
"sublime_text.exe": true,
|
||||||
|
"notepad++.exe": true,
|
||||||
|
"devenv.exe": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func isDescendant(pid, targetPid uint32, parentMap map[uint32]uint32) bool {
|
||||||
|
curr := pid
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
parent, ok := parentMap[curr]
|
||||||
|
if !ok || parent == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parent == targetPid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
curr = parent
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Blocker) checkAndKill() {
|
||||||
|
b.mu.Lock()
|
||||||
|
keywords := make([]string, len(b.allowedKeywords))
|
||||||
|
copy(keywords, b.allowedKeywords)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
// Nếu không cấu hình keyword thì không chặn gì cả
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentExec := ""
|
||||||
|
if execPath, err := os.Executable(); err == nil {
|
||||||
|
currentExec = strings.ToLower(filepath.Base(execPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
windows, parentMap, err := EnumerateGUIWindows()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myPid := uint32(os.Getpid())
|
||||||
|
for _, w := range windows {
|
||||||
|
pNameLower := strings.ToLower(w.ProcessName)
|
||||||
|
wTitleLower := strings.ToLower(w.Title)
|
||||||
|
|
||||||
|
// 1. Luôn cho phép hệ thống/app cốt lõi hoặc chính tiến trình này (bao gồm tiến trình con/cháu, và đổi tên)
|
||||||
|
isOurApp := w.PID == myPid || isDescendant(w.PID, myPid, parentMap)
|
||||||
|
if isOurApp || (currentExec != "" && pNameLower == currentExec) || systemAllowed[pNameLower] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Kiểm tra xem có chứa bất kỳ từ khóa nào được cho phép không
|
||||||
|
allowed := false
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if matchesAllowedKeyword(kw, pNameLower, wTitleLower) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Nếu không nằm trong whitelist, tắt ứng dụng
|
||||||
|
if !allowed {
|
||||||
|
if b.OnBlocked != nil {
|
||||||
|
b.OnBlocked(w.ProcessName, w.Title)
|
||||||
|
}
|
||||||
|
log.Printf("[BLOCKER] KILLED unauthorized application: %s (PID: %d, Title: %s)", w.ProcessName, w.PID, w.Title)
|
||||||
|
h, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, w.PID)
|
||||||
|
if err == nil {
|
||||||
|
errTerm := syscall.TerminateProcess(h, 0)
|
||||||
|
_ = syscall.CloseHandle(h)
|
||||||
|
if errTerm == nil && b.OnKill != nil {
|
||||||
|
b.OnKill(w.ProcessName, w.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
client/internal/camera/camera_darwin.go
Normal file
48
client/internal/camera/camera_darwin.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package camera
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreImage -framework CoreMedia -framework CoreVideo -framework ImageIO -framework AppKit
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
int StartNativeCamera(void);
|
||||||
|
void StopNativeCamera(void);
|
||||||
|
char* GetLatestCameraFrame(void);
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const IsNative = true
|
||||||
|
|
||||||
|
// StartCapture starts native camera capture via AVCaptureSession
|
||||||
|
func StartCapture() error {
|
||||||
|
ret := C.StartNativeCamera()
|
||||||
|
if ret != 0 {
|
||||||
|
log.Println("[CAMERA] Failed to start native camera capture")
|
||||||
|
return fmt.Errorf("failed to start camera (code %d)", int(ret))
|
||||||
|
}
|
||||||
|
log.Println("[CAMERA] Native camera capture started successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCapture stops native camera capture
|
||||||
|
func StopCapture() {
|
||||||
|
C.StopNativeCamera()
|
||||||
|
log.Println("[CAMERA] Native camera capture stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrame returns the latest camera frame as a data:image/jpeg;base64,... string
|
||||||
|
// Returns empty string if no frame is available
|
||||||
|
func GetFrame() string {
|
||||||
|
cstr := C.GetLatestCameraFrame()
|
||||||
|
if cstr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer C.free(unsafe.Pointer(cstr))
|
||||||
|
return C.GoString(cstr)
|
||||||
|
}
|
||||||
217
client/internal/camera/camera_darwin.m
Normal file
217
client/internal/camera/camera_darwin.m
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <CoreImage/CoreImage.h>
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
// ── Shared state ──────────────────────────────────────────────
|
||||||
|
static AVCaptureSession *captureSession = nil;
|
||||||
|
static AVCaptureVideoDataOutput *videoOutput = nil;
|
||||||
|
static dispatch_queue_t captureQueue = nil;
|
||||||
|
static CIContext *sharedCIContext = nil;
|
||||||
|
|
||||||
|
// Latest frame stored as JPEG base64 (owning reference)
|
||||||
|
static NSString *latestFrameBase64 = nil;
|
||||||
|
static NSLock *frameLock = nil;
|
||||||
|
static int frameCount = 0;
|
||||||
|
|
||||||
|
// ── Delegate that receives sample buffers ─────────────────────
|
||||||
|
@interface CameraFrameDelegate : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate>
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation CameraFrameDelegate
|
||||||
|
|
||||||
|
- (void)captureOutput:(AVCaptureOutput *)output
|
||||||
|
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
||||||
|
fromConnection:(AVCaptureConnection *)connection {
|
||||||
|
@autoreleasepool {
|
||||||
|
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
|
||||||
|
if (!imageBuffer) {
|
||||||
|
NSLog(@"[CAMERA] didOutputSampleBuffer: imageBuffer is NULL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CIImage *ciImage = [CIImage imageWithCVPixelBuffer:imageBuffer];
|
||||||
|
if (!sharedCIContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CGImageRef cgImage = [sharedCIContext createCGImage:ciImage fromRect:ciImage.extent];
|
||||||
|
if (!cgImage) {
|
||||||
|
NSLog(@"[CAMERA] didOutputSampleBuffer: cgImage is NULL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to JPEG NSData (quality ≈ 0.4)
|
||||||
|
NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage];
|
||||||
|
CGImageRelease(cgImage);
|
||||||
|
if (!rep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSDictionary *props = @{NSImageCompressionFactor: @(0.4)};
|
||||||
|
NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props];
|
||||||
|
if (!jpegData) {
|
||||||
|
NSLog(@"[CAMERA] didOutputSampleBuffer: jpegData is NULL");
|
||||||
|
[rep release];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSString *b64 = [jpegData base64EncodedStringWithOptions:0];
|
||||||
|
// Create a retained string (ownership transfer)
|
||||||
|
NSString *dataUrl = [[NSString alloc] initWithFormat:@"data:image/jpeg;base64,%@", b64];
|
||||||
|
[rep release];
|
||||||
|
|
||||||
|
[frameLock lock];
|
||||||
|
if (latestFrameBase64) {
|
||||||
|
[latestFrameBase64 release];
|
||||||
|
}
|
||||||
|
latestFrameBase64 = dataUrl; // retained copy
|
||||||
|
frameCount++;
|
||||||
|
int fc = frameCount;
|
||||||
|
[frameLock unlock];
|
||||||
|
|
||||||
|
// Log first few frames to confirm camera is working
|
||||||
|
if (fc <= 3) {
|
||||||
|
NSLog(@"[CAMERA] Frame #%d captured, size=%lu bytes", fc, (unsigned long)jpegData.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@end
|
||||||
|
|
||||||
|
static CameraFrameDelegate *frameDelegate = nil;
|
||||||
|
|
||||||
|
// ── C-exported functions ──────────────────────────────────────
|
||||||
|
|
||||||
|
// StartNativeCamera: returns 0 on success, -1 on failure
|
||||||
|
int StartNativeCamera(void) {
|
||||||
|
NSLog(@"[CAMERA] StartNativeCamera called");
|
||||||
|
|
||||||
|
if (captureSession && captureSession.isRunning) {
|
||||||
|
NSLog(@"[CAMERA] Session already running");
|
||||||
|
return 0; // already running
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!frameLock) {
|
||||||
|
frameLock = [[NSLock alloc] init];
|
||||||
|
}
|
||||||
|
frameCount = 0;
|
||||||
|
|
||||||
|
// Check camera permission
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||||
|
NSLog(@"[CAMERA] Camera permission status: %ld (0=NotDetermined, 1=Restricted, 2=Denied, 3=Authorized)", (long)status);
|
||||||
|
|
||||||
|
if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
|
||||||
|
NSLog(@"[CAMERA] Camera permission denied (status=%ld)", (long)status);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (status == AVAuthorizationStatusNotDetermined) {
|
||||||
|
NSLog(@"[CAMERA] Requesting camera permission...");
|
||||||
|
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||||
|
__block BOOL granted = NO;
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL g) {
|
||||||
|
granted = g;
|
||||||
|
NSLog(@"[CAMERA] Permission request result: %@", g ? @"GRANTED" : @"DENIED");
|
||||||
|
dispatch_semaphore_signal(sem);
|
||||||
|
}];
|
||||||
|
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC));
|
||||||
|
if (!granted) {
|
||||||
|
NSLog(@"[CAMERA] Camera permission not granted after request");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sharedCIContext) {
|
||||||
|
sharedCIContext = [[CIContext contextWithOptions:nil] retain];
|
||||||
|
}
|
||||||
|
|
||||||
|
captureSession = [[AVCaptureSession alloc] init];
|
||||||
|
captureSession.sessionPreset = AVCaptureSessionPresetLow; // 320×240-ish
|
||||||
|
NSLog(@"[CAMERA] Session created with preset Low");
|
||||||
|
|
||||||
|
// Find default video device
|
||||||
|
AVCaptureDevice *camera = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo];
|
||||||
|
if (!camera) {
|
||||||
|
NSLog(@"[CAMERA] No camera device found");
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
NSLog(@"[CAMERA] Found camera: %@", camera.localizedName);
|
||||||
|
|
||||||
|
NSError *error = nil;
|
||||||
|
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:camera error:&error];
|
||||||
|
if (error || !input) {
|
||||||
|
NSLog(@"[CAMERA] Cannot create camera input: %@", error);
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([captureSession canAddInput:input]) {
|
||||||
|
[captureSession addInput:input];
|
||||||
|
NSLog(@"[CAMERA] Input added to session");
|
||||||
|
} else {
|
||||||
|
NSLog(@"[CAMERA] Cannot add camera input to session");
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video data output
|
||||||
|
videoOutput = [[AVCaptureVideoDataOutput alloc] init];
|
||||||
|
videoOutput.videoSettings = @{
|
||||||
|
(NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)
|
||||||
|
};
|
||||||
|
videoOutput.alwaysDiscardsLateVideoFrames = YES;
|
||||||
|
|
||||||
|
captureQueue = dispatch_queue_create("com.simplecare.camera", DISPATCH_QUEUE_SERIAL);
|
||||||
|
frameDelegate = [[CameraFrameDelegate alloc] init];
|
||||||
|
[videoOutput setSampleBufferDelegate:frameDelegate queue:captureQueue];
|
||||||
|
|
||||||
|
if ([captureSession canAddOutput:videoOutput]) {
|
||||||
|
[captureSession addOutput:videoOutput];
|
||||||
|
NSLog(@"[CAMERA] Output added to session");
|
||||||
|
} else {
|
||||||
|
NSLog(@"[CAMERA] Cannot add video output to session");
|
||||||
|
captureSession = nil;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[captureSession startRunning];
|
||||||
|
NSLog(@"[CAMERA] Session startRunning called, isRunning=%d", captureSession.isRunning);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void StopNativeCamera(void) {
|
||||||
|
NSLog(@"[CAMERA] StopNativeCamera called");
|
||||||
|
if (captureSession && captureSession.isRunning) {
|
||||||
|
[captureSession stopRunning];
|
||||||
|
NSLog(@"[CAMERA] Session stopped");
|
||||||
|
}
|
||||||
|
captureSession = nil;
|
||||||
|
videoOutput = nil;
|
||||||
|
frameDelegate = nil;
|
||||||
|
|
||||||
|
[frameLock lock];
|
||||||
|
if (latestFrameBase64) {
|
||||||
|
[latestFrameBase64 release];
|
||||||
|
latestFrameBase64 = nil;
|
||||||
|
}
|
||||||
|
[frameLock unlock];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestCameraFrame: returns a C-string (caller must free) or NULL
|
||||||
|
char* GetLatestCameraFrame(void) {
|
||||||
|
[frameLock lock];
|
||||||
|
NSString *frame = latestFrameBase64;
|
||||||
|
latestFrameBase64 = nil; // transfers ownership to caller
|
||||||
|
[frameLock unlock];
|
||||||
|
|
||||||
|
if (!frame) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
char *res = strdup([frame UTF8String]);
|
||||||
|
[frame release]; // Release since we had ownership
|
||||||
|
return res;
|
||||||
|
}
|
||||||
22
client/internal/camera/camera_other.go
Normal file
22
client/internal/camera/camera_other.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package camera
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
const IsNative = false
|
||||||
|
|
||||||
|
// StartCapture is a no-op on non-darwin platforms (Windows uses different webcam API)
|
||||||
|
func StartCapture() error {
|
||||||
|
log.Println("[CAMERA] Native camera capture not supported on this platform")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCapture is a no-op on non-darwin platforms
|
||||||
|
func StopCapture() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrame always returns empty on non-darwin platforms
|
||||||
|
func GetFrame() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
89
client/internal/guard/guard_darwin.go
Normal file
89
client/internal/guard/guard_darwin.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kbinani/screenshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
guardOnce sync.Once
|
||||||
|
guardViolation func(kind, reason string)
|
||||||
|
guardStop chan struct{}
|
||||||
|
suppressMu sync.Mutex
|
||||||
|
suppressViolationsUntil time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ.
|
||||||
|
func SuppressFor(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressMu.Lock()
|
||||||
|
next := time.Now().Add(d)
|
||||||
|
if next.After(suppressViolationsUntil) {
|
||||||
|
suppressViolationsUntil = next
|
||||||
|
}
|
||||||
|
suppressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func violationsSuppressed() bool {
|
||||||
|
suppressMu.Lock()
|
||||||
|
defer suppressMu.Unlock()
|
||||||
|
return time.Now().Before(suppressViolationsUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason).
|
||||||
|
func Start(onViolation func(kind, reason string)) {
|
||||||
|
guardOnce.Do(func() {
|
||||||
|
if onViolation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guardViolation = onViolation
|
||||||
|
guardStop = make(chan struct{})
|
||||||
|
|
||||||
|
if screenshot.NumActiveDisplays() > 1 {
|
||||||
|
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go pollLoop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {
|
||||||
|
if guardStop != nil {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(guardStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollLoop() {
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkEnvironment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEnvironment() {
|
||||||
|
if guardViolation == nil || violationsSuppressed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := screenshot.NumActiveDisplays(); n > 1 {
|
||||||
|
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
90
client/internal/guard/guard_linux.go
Normal file
90
client/internal/guard/guard_linux.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package guard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kbinani/screenshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
guardOnce sync.Once
|
||||||
|
guardViolation func(kind, reason string)
|
||||||
|
guardStop chan struct{}
|
||||||
|
suppressMu sync.Mutex
|
||||||
|
suppressViolationsUntil time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuppressFor temporarily suspends environment violation checks (e.g. when transition screen)
|
||||||
|
func SuppressFor(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressMu.Lock()
|
||||||
|
next := time.Now().Add(d)
|
||||||
|
if next.After(suppressViolationsUntil) {
|
||||||
|
suppressViolationsUntil = next
|
||||||
|
}
|
||||||
|
suppressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func violationsSuppressed() bool {
|
||||||
|
suppressMu.Lock()
|
||||||
|
defer suppressMu.Unlock()
|
||||||
|
return time.Now().Before(suppressViolationsUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start monitors the Linux desktop environment (multi-display check)
|
||||||
|
func Start(onViolation func(kind, reason string)) {
|
||||||
|
guardOnce.Do(func() {
|
||||||
|
if onViolation == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guardViolation = onViolation
|
||||||
|
guardStop = make(chan struct{})
|
||||||
|
|
||||||
|
if screenshot.NumActiveDisplays() > 1 {
|
||||||
|
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go pollLoop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the environment guard
|
||||||
|
func Stop() {
|
||||||
|
if guardStop != nil {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(guardStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollLoop() {
|
||||||
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-guardStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
checkEnvironment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEnvironment() {
|
||||||
|
if guardViolation == nil || violationsSuppressed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n := screenshot.NumActiveDisplays(); n > 1 {
|
||||||
|
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
//go:build !windows
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
package guard
|
package guard
|
||||||
|
|
||||||
func Start(onViolation func(reason string)) {}
|
import "time"
|
||||||
|
|
||||||
|
func Start(onViolation func(kind, reason string)) {}
|
||||||
func Stop() {}
|
func Stop() {}
|
||||||
|
func SuppressFor(_ time.Duration) {}
|
||||||
|
|||||||
@@ -72,17 +72,38 @@ type msg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
guardOnce sync.Once
|
guardOnce sync.Once
|
||||||
guardViolation func(string)
|
guardViolation func(kind, reason string)
|
||||||
guardStop chan struct{}
|
guardStop chan struct{}
|
||||||
guardBaselineUser string
|
guardBaselineUser string
|
||||||
guardBaselineSession uint32
|
guardBaselineSession uint32
|
||||||
desktopHook uintptr
|
desktopHook uintptr
|
||||||
guardClassAtom uint16
|
guardClassAtom uint16
|
||||||
|
suppressMu sync.Mutex
|
||||||
|
suppressViolationsUntil time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo).
|
// SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ.
|
||||||
func Start(onViolation func(reason string)) {
|
func SuppressFor(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressMu.Lock()
|
||||||
|
next := time.Now().Add(d)
|
||||||
|
if next.After(suppressViolationsUntil) {
|
||||||
|
suppressViolationsUntil = next
|
||||||
|
}
|
||||||
|
suppressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func violationsSuppressed() bool {
|
||||||
|
suppressMu.Lock()
|
||||||
|
defer suppressMu.Unlock()
|
||||||
|
return time.Now().Before(suppressViolationsUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason).
|
||||||
|
func Start(onViolation func(kind, reason string)) {
|
||||||
guardOnce.Do(func() {
|
guardOnce.Do(func() {
|
||||||
if onViolation == nil {
|
if onViolation == nil {
|
||||||
return
|
return
|
||||||
@@ -93,7 +114,7 @@ func Start(onViolation func(reason string)) {
|
|||||||
guardBaselineSession = currentSessionID()
|
guardBaselineSession = currentSessionID()
|
||||||
|
|
||||||
if monitorCount() > 1 {
|
if monitorCount() > 1 {
|
||||||
onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,27 +143,31 @@ func pollLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkEnvironment() {
|
func checkEnvironment() {
|
||||||
if guardViolation == nil {
|
if guardViolation == nil || violationsSuppressed() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n := monitorCount(); n > 1 {
|
if n := monitorCount(); n > 1 {
|
||||||
guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := currentUsername()
|
user := currentUsername()
|
||||||
if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
|
if user != "" && guardBaselineUser != "" && user != guardBaselineUser {
|
||||||
guardViolation("Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
|
guardViolation("user_switch", "Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sid := currentSessionID()
|
sid := currentSessionID()
|
||||||
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
|
if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession {
|
||||||
guardViolation("Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
|
guardViolation("session_change", "Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerViolation(reason string) {
|
func triggerViolation(kind, reason string) {
|
||||||
|
if violationsSuppressed() {
|
||||||
|
log.Printf("[GUARD] suppressed: %s", reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
if guardViolation != nil {
|
if guardViolation != nil {
|
||||||
guardViolation(reason)
|
guardViolation(kind, reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
|||||||
case wmWtsSessionChange:
|
case wmWtsSessionChange:
|
||||||
switch uint32(wParam) {
|
switch uint32(wParam) {
|
||||||
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
|
case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect:
|
||||||
triggerViolation("Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
|
triggerViolation("session_change", "Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
|
r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam)
|
||||||
@@ -246,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr {
|
|||||||
|
|
||||||
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
|
func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr {
|
||||||
if event == eventSystemDesktopSwitch {
|
if event == eventSystemDesktopSwitch {
|
||||||
triggerViolation("Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
|
triggerViolation("virtual_desktop", "Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.")
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package screen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"image/jpeg"
|
|
||||||
|
|
||||||
"github.com/kbinani/screenshot"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CaptureScreen chụp màn hình chính và trả về chuỗi Base64 dạng "data:image/jpeg;base64,..."
|
|
||||||
func CaptureScreen() (string, error) {
|
|
||||||
n := screenshot.NumActiveDisplays()
|
|
||||||
if n <= 0 {
|
|
||||||
return "", fmt.Errorf("no active displays found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chụp màn hình chính (index 0)
|
|
||||||
bounds := screenshot.GetDisplayBounds(0)
|
|
||||||
img, err := screenshot.CaptureRect(bounds)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to capture screen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to encode jpeg: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
|
|
||||||
return "data:image/jpeg;base64," + encoded, nil
|
|
||||||
}
|
|
||||||
152
client/internal/screen/screen_darwin.go
Normal file
152
client/internal/screen/screen_darwin.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package screen
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -x objective-c -Wno-deprecated-declarations -Wno-unguarded-availability-new
|
||||||
|
#cgo LDFLAGS: -framework CoreGraphics -framework Foundation -framework AppKit
|
||||||
|
|
||||||
|
// Disable availability checks - we handle this at runtime
|
||||||
|
#define __API_UNAVAILABLE(...)
|
||||||
|
#define API_UNAVAILABLE(...)
|
||||||
|
|
||||||
|
#import <CoreGraphics/CoreGraphics.h>
|
||||||
|
#import <AppKit/AppKit.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
// Use dlsym to call CGWindowListCreateImage dynamically to bypass macOS 15 availability check
|
||||||
|
typedef CGImageRef (*CGWindowListCreateImageFunc)(CGRect, CGWindowListOption, CGWindowID, CGWindowImageOption);
|
||||||
|
|
||||||
|
static unsigned char* CaptureFullDesktop(int* outLen, int quality) {
|
||||||
|
// Dynamically load CGWindowListCreateImage to bypass compile-time availability check
|
||||||
|
static CGWindowListCreateImageFunc createImageFunc = NULL;
|
||||||
|
if (!createImageFunc) {
|
||||||
|
void *handle = dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", RTLD_LAZY);
|
||||||
|
if (handle) {
|
||||||
|
createImageFunc = (CGWindowListCreateImageFunc)dlsym(handle, "CGWindowListCreateImage");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createImageFunc) {
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
CGImageRef image = createImageFunc(
|
||||||
|
CGRectInfinite,
|
||||||
|
kCGWindowListOptionOnScreenOnly,
|
||||||
|
kCGNullWindowID,
|
||||||
|
kCGWindowImageDefault
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@autoreleasepool {
|
||||||
|
// 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;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
float q = (float)quality / 100.0f;
|
||||||
|
if (q < 0.1f) q = 0.1f;
|
||||||
|
if (q > 1.0f) q = 1.0f;
|
||||||
|
|
||||||
|
NSDictionary *props = @{NSImageCompressionFactor: @(q)};
|
||||||
|
NSData *jpegData = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props];
|
||||||
|
|
||||||
|
if (!jpegData || jpegData.length == 0) {
|
||||||
|
[rep release];
|
||||||
|
*outLen = 0;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
*outLen = (int)jpegData.length;
|
||||||
|
unsigned char *buf = (unsigned char*)malloc(jpegData.length);
|
||||||
|
memcpy(buf, jpegData.bytes, jpegData.length);
|
||||||
|
[rep release];
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptureScreen captures the full desktop on macOS using CGWindowListCreateImage (via dlsym)
|
||||||
|
// Returns a data:image/jpeg;base64,... string
|
||||||
|
func CaptureScreen() (string, error) {
|
||||||
|
var outLen C.int
|
||||||
|
quality := C.int(50) // JPEG quality 50%
|
||||||
|
|
||||||
|
buf := C.CaptureFullDesktop(&outLen, quality)
|
||||||
|
if buf == nil || int(outLen) == 0 {
|
||||||
|
return "", fmt.Errorf("failed to capture screen: CGWindowListCreateImage returned nil")
|
||||||
|
}
|
||||||
|
defer C.free(unsafe.Pointer(buf))
|
||||||
|
|
||||||
|
jpegBytes := C.GoBytes(unsafe.Pointer(buf), outLen)
|
||||||
|
|
||||||
|
if len(jpegBytes) < 100 {
|
||||||
|
log.Printf("[SCREEN] Warning: captured image is very small (%d bytes), may indicate permission issue", len(jpegBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(jpegBytes)
|
||||||
|
return "data:image/jpeg;base64," + encoded, nil
|
||||||
|
}
|
||||||
89
client/internal/screen/screen_other.go
Normal file
89
client/internal/screen/screen_other.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package screen
|
||||||
|
|
||||||
|
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) {
|
||||||
|
n := screenshot.NumActiveDisplays()
|
||||||
|
if n <= 0 {
|
||||||
|
return "", fmt.Errorf("no active displays found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chụp màn hình chính (index 0)
|
||||||
|
bounds := screenshot.GetDisplayBounds(0)
|
||||||
|
img, err := screenshot.CaptureRect(bounds)
|
||||||
|
if err != nil {
|
||||||
|
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, finalImg, &jpeg.Options{Quality: 50})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode jpeg: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
return "data:image/jpeg;base64," + encoded, nil
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
83
client/internal/winapi/wifi_darwin.go
Normal file
83
client/internal/winapi/wifi_darwin.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo LDFLAGS: -framework CoreWLAN -framework CoreLocation -framework Foundation -framework AVFoundation -framework CoreGraphics
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char* ssid;
|
||||||
|
char* bssid;
|
||||||
|
} CWifiInfo;
|
||||||
|
|
||||||
|
void RequestLocationPermission();
|
||||||
|
void RequestCameraAndMicPermission();
|
||||||
|
void RequestScreenCapturePermission();
|
||||||
|
CWifiInfo GetCurrentWifiInfo();
|
||||||
|
int GetCameraPermissionStatus();
|
||||||
|
int GetMicrophonePermissionStatus();
|
||||||
|
int GetScreenCapturePermissionStatus();
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestLocationAccess requests Location permission on macOS
|
||||||
|
func RequestLocationAccess() {
|
||||||
|
C.RequestLocationPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCameraAndMicAccess requests Camera and Microphone permissions on macOS
|
||||||
|
func RequestCameraAndMicAccess() {
|
||||||
|
C.RequestCameraAndMicPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestScreenCaptureAccess requests Screen Capture permission on macOS
|
||||||
|
func RequestScreenCaptureAccess() {
|
||||||
|
C.RequestScreenCapturePermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCameraPermission status on macOS
|
||||||
|
func GetCameraPermission() int {
|
||||||
|
return int(C.GetCameraPermissionStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMicrophonePermission status on macOS
|
||||||
|
func GetMicrophonePermission() int {
|
||||||
|
return int(C.GetMicrophonePermissionStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScreenCapturePermission status on macOS
|
||||||
|
func GetScreenCapturePermission() int {
|
||||||
|
return int(C.GetScreenCapturePermissionStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWifiConnection đọc SSID và BSSID từ CoreWLAN (macOS)
|
||||||
|
func GetWifiConnection() WifiConnection {
|
||||||
|
info := C.GetCurrentWifiInfo()
|
||||||
|
defer func() {
|
||||||
|
if info.ssid != nil {
|
||||||
|
C.free(unsafe.Pointer(info.ssid))
|
||||||
|
}
|
||||||
|
if info.bssid != nil {
|
||||||
|
C.free(unsafe.Pointer(info.bssid))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ssid := ""
|
||||||
|
if info.ssid != nil {
|
||||||
|
ssid = C.GoString(info.ssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
bssid := ""
|
||||||
|
if info.bssid != nil {
|
||||||
|
bssid = C.GoString(info.bssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return WifiConnection{
|
||||||
|
SSID: ssid,
|
||||||
|
BSSID: normalizeMAC(bssid),
|
||||||
|
}
|
||||||
|
}
|
||||||
101
client/internal/winapi/wifi_darwin.m
Normal file
101
client/internal/winapi/wifi_darwin.m
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#import <CoreWLAN/CoreWLAN.h>
|
||||||
|
#import <CoreLocation/CoreLocation.h>
|
||||||
|
#import <AVFoundation/AVFoundation.h>
|
||||||
|
#import <CoreGraphics/CoreGraphics.h>
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
static CLLocationManager *locationManager = nil;
|
||||||
|
|
||||||
|
void RequestLocationPermission() {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (locationManager == nil) {
|
||||||
|
locationManager = [[CLLocationManager alloc] init];
|
||||||
|
}
|
||||||
|
if (@available(macOS 10.15, *)) {
|
||||||
|
[locationManager requestWhenInUseAuthorization];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RequestCameraAndMicPermission() {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
// Request Camera
|
||||||
|
AVAuthorizationStatus cameraStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||||
|
if (cameraStatus == AVAuthorizationStatusNotDetermined) {
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
|
||||||
|
// Camera permission requested
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request Microphone
|
||||||
|
AVAuthorizationStatus micStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
|
||||||
|
if (micStatus == AVAuthorizationStatusNotDetermined) {
|
||||||
|
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL granted) {
|
||||||
|
// Mic permission requested
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void RequestScreenCapturePermission() {
|
||||||
|
dispatch_async(dispatch_get_main_queue(), ^{
|
||||||
|
if (@available(macOS 11.0, *)) {
|
||||||
|
BOOL hasAccess = CGPreflightScreenCaptureAccess();
|
||||||
|
if (!hasAccess) {
|
||||||
|
CGRequestScreenCaptureAccess();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetCameraPermissionStatus() {
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
return (int)[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetMicrophonePermissionStatus() {
|
||||||
|
if (@available(macOS 10.14, *)) {
|
||||||
|
return (int)[AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int GetScreenCapturePermissionStatus() {
|
||||||
|
if (@available(macOS 11.0, *)) {
|
||||||
|
return CGPreflightScreenCaptureAccess() ? 1 : 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char* ssid;
|
||||||
|
char* bssid;
|
||||||
|
} CWifiInfo;
|
||||||
|
|
||||||
|
CWifiInfo GetCurrentWifiInfo() {
|
||||||
|
CWifiInfo info;
|
||||||
|
info.ssid = NULL;
|
||||||
|
info.bssid = NULL;
|
||||||
|
|
||||||
|
@autoreleasepool {
|
||||||
|
CWWiFiClient *client = [[CWWiFiClient alloc] init];
|
||||||
|
if (client != nil) {
|
||||||
|
CWInterface *interface = [client interface];
|
||||||
|
if (interface != nil) {
|
||||||
|
NSString *ssidStr = [interface ssid];
|
||||||
|
NSString *bssidStr = [interface bssid];
|
||||||
|
if (ssidStr != nil) {
|
||||||
|
info.ssid = strdup([ssidStr UTF8String]);
|
||||||
|
}
|
||||||
|
if (bssidStr != nil) {
|
||||||
|
info.bssid = strdup([bssidStr UTF8String]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
26
client/internal/winapi/wifi_other.go
Normal file
26
client/internal/winapi/wifi_other.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
//go:build !windows && !darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
// GetWifiConnection returns an empty WifiConnection stub for other systems
|
||||||
|
func GetWifiConnection() WifiConnection {
|
||||||
|
return WifiConnection{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLocationAccess requests Location permission (stub for other systems)
|
||||||
|
func RequestLocationAccess() {}
|
||||||
|
|
||||||
|
// RequestCameraAndMicAccess stub
|
||||||
|
func RequestCameraAndMicAccess() {}
|
||||||
|
|
||||||
|
// RequestScreenCaptureAccess stub
|
||||||
|
func RequestScreenCaptureAccess() {}
|
||||||
|
|
||||||
|
// GetCameraPermission stub
|
||||||
|
func GetCameraPermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetMicrophonePermission stub
|
||||||
|
func GetMicrophonePermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetScreenCapturePermission stub
|
||||||
|
func GetScreenCapturePermission() int { return -1 }
|
||||||
58
client/internal/winapi/wifi_windows.go
Normal file
58
client/internal/winapi/wifi_windows.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetWifiConnection đọc SSID và BSSID từ netsh (Windows)
|
||||||
|
func GetWifiConnection() WifiConnection {
|
||||||
|
cmd := exec.Command("netsh", "wlan", "show", "interfaces")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return WifiConnection{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var conn WifiConnection
|
||||||
|
for _, line := range strings.Split(out.String(), "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if strings.HasPrefix(lower, "ssid") && !strings.Contains(lower, "bssid") {
|
||||||
|
if v := valueAfterColon(trimmed); v != "" {
|
||||||
|
conn.SSID = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Windows: "AP BSSID" (EN) hoặc dòng có chứa "bssid" (locale khác)
|
||||||
|
if strings.Contains(lower, "bssid") {
|
||||||
|
if v := valueAfterColon(trimmed); v != "" {
|
||||||
|
conn.BSSID = normalizeMAC(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLocationAccess requests Location permission (stub for Windows)
|
||||||
|
func RequestLocationAccess() {}
|
||||||
|
|
||||||
|
// RequestCameraAndMicAccess stub
|
||||||
|
func RequestCameraAndMicAccess() {}
|
||||||
|
|
||||||
|
// RequestScreenCaptureAccess stub
|
||||||
|
func RequestScreenCaptureAccess() {}
|
||||||
|
|
||||||
|
// GetCameraPermission stub
|
||||||
|
func GetCameraPermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetMicrophonePermission stub
|
||||||
|
func GetMicrophonePermission() int { return -1 }
|
||||||
|
|
||||||
|
// GetScreenCapturePermission stub
|
||||||
|
func GetScreenCapturePermission() int { return -1 }
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package winapi
|
package winapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WifiConnection — SSID + BSSID (MAC) của điểm phát đang kết nối
|
// WifiConnection — SSID + BSSID (MAC) của điểm phát đang kết nối
|
||||||
@@ -18,36 +15,6 @@ func GetWifiSSID() string {
|
|||||||
return GetWifiConnection().SSID
|
return GetWifiConnection().SSID
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWifiConnection đọc SSID và BSSID từ netsh (Windows)
|
|
||||||
func GetWifiConnection() WifiConnection {
|
|
||||||
cmd := exec.Command("netsh", "wlan", "show", "interfaces")
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return WifiConnection{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var conn WifiConnection
|
|
||||||
for _, line := range strings.Split(out.String(), "\n") {
|
|
||||||
trimmed := strings.TrimSpace(line)
|
|
||||||
lower := strings.ToLower(trimmed)
|
|
||||||
if strings.HasPrefix(lower, "ssid") && !strings.Contains(lower, "bssid") {
|
|
||||||
if v := valueAfterColon(trimmed); v != "" {
|
|
||||||
conn.SSID = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Windows: "AP BSSID" (EN) hoặc dòng có chứa "bssid" (locale khác)
|
|
||||||
if strings.Contains(lower, "bssid") {
|
|
||||||
if v := valueAfterColon(trimmed); v != "" {
|
|
||||||
conn.BSSID = normalizeMAC(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueAfterColon(line string) string {
|
func valueAfterColon(line string) string {
|
||||||
idx := strings.Index(line, ":")
|
idx := strings.Index(line, ":")
|
||||||
if idx < 0 {
|
if idx < 0 {
|
||||||
|
|||||||
39
client/internal/winapi/window_darwin.go
Normal file
39
client/internal/winapi/window_darwin.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivateAppWindow — đưa cửa sổ Simple Care đang chạy lên trước (theo tên process / title).
|
||||||
|
func ActivateAppWindow(titleHint string) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayNotifySound — beep hệ thống macOS
|
||||||
|
func PlayNotifySound() {
|
||||||
|
cmd := exec.Command("osascript", "-e", "beep")
|
||||||
|
_ = cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowWarningMessageBox hiển thị hộp thoại cảnh báo macOS bất đồng bộ
|
||||||
|
func ShowWarningMessageBox(title, message string) {
|
||||||
|
script := fmt.Sprintf(`display alert %q message %q`, title, message)
|
||||||
|
cmd := exec.Command("osascript", "-e", script)
|
||||||
|
_ = cmd.Start() // Run asynchronously
|
||||||
|
}
|
||||||
40
client/internal/winapi/window_linux.go
Normal file
40
client/internal/winapi/window_linux.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActivateAppWindow attempts to focus the application window.
|
||||||
|
// On Linux standard X11/Wayland desktop, focus is managed by the WM.
|
||||||
|
func ActivateAppWindow(titleHint string) {
|
||||||
|
log.Printf("[WINDOW] ActivateAppWindow requested for: %s", titleHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayNotifySound plays a system notification sound using canberra-gtk-play, falling back to pw-play or aplay.
|
||||||
|
func PlayNotifySound() {
|
||||||
|
log.Println("[WINDOW] Playing notification sound...")
|
||||||
|
go func() {
|
||||||
|
// Try canberra-gtk-play first
|
||||||
|
cmd := exec.Command("canberra-gtk-play", "-i", "bell")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Fallback to pw-play (Pipewire)
|
||||||
|
cmd = exec.Command("pw-play", "/usr/share/sounds/freedesktop/stereo/bell.oga")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Fallback to aplay (ALSA)
|
||||||
|
_ = exec.Command("aplay", "/usr/share/sounds/alsa/Front_Center.wav").Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowWarningMessageBox displays an asynchronous GUI warning dialog using zenity.
|
||||||
|
func ShowWarningMessageBox(title, message string) {
|
||||||
|
log.Printf("[WINDOW] Warning Message Box: %s - %s", title, message)
|
||||||
|
go func() {
|
||||||
|
cmd := exec.Command("zenity", "--warning", "--title="+title, "--text="+message, "--no-wrap")
|
||||||
|
_ = cmd.Run()
|
||||||
|
}()
|
||||||
|
}
|
||||||
9
client/internal/winapi/window_other.go
Normal file
9
client/internal/winapi/window_other.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows && !darwin && !linux
|
||||||
|
|
||||||
|
package winapi
|
||||||
|
|
||||||
|
func ActivateAppWindow(titleHint string) {}
|
||||||
|
|
||||||
|
func PlayNotifySound() {}
|
||||||
|
|
||||||
|
func ShowWarningMessageBox(title, message string) {}
|
||||||
@@ -19,6 +19,7 @@ var (
|
|||||||
procSetForegroundWindow = user32Window.NewProc("SetForegroundWindow")
|
procSetForegroundWindow = user32Window.NewProc("SetForegroundWindow")
|
||||||
procFlashWindowEx = user32Window.NewProc("FlashWindowEx")
|
procFlashWindowEx = user32Window.NewProc("FlashWindowEx")
|
||||||
procMessageBeep = user32Window.NewProc("MessageBeep")
|
procMessageBeep = user32Window.NewProc("MessageBeep")
|
||||||
|
procMessageBoxW = user32Window.NewProc("MessageBoxW")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -89,3 +90,13 @@ func findWindowByTitleContains(hint string) uintptr {
|
|||||||
_, _, _ = procEnumWindows.Call(enumWindowsCallback, 0)
|
_, _, _ = procEnumWindows.Call(enumWindowsCallback, 0)
|
||||||
return enumFoundHwnd
|
return enumFoundHwnd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowWarningMessageBox hiển thị hộp thoại cảnh báo Win32 bất đồng bộ
|
||||||
|
func ShowWarningMessageBox(title, message string) {
|
||||||
|
titlePtr, _ := syscall.UTF16PtrFromString(title)
|
||||||
|
messagePtr, _ := syscall.UTF16PtrFromString(message)
|
||||||
|
go func() {
|
||||||
|
// MB_ICONWARNING = 0x00000030, MB_TOPMOST = 0x00040000
|
||||||
|
_, _, _ = procMessageBoxW.Call(0, uintptr(unsafe.Pointer(messagePtr)), uintptr(unsafe.Pointer(titlePtr)), 0x00040030)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,82 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"client/internal/singleinstance"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/menu/keys"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
func initLogging() *os.File {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
appDir := filepath.Join(configDir, "SimpleCare")
|
||||||
|
_ = os.MkdirAll(appDir, 0755)
|
||||||
|
logFile, err := os.OpenFile(filepath.Join(appDir, "app.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
|
if err == nil {
|
||||||
|
log.SetOutput(logFile)
|
||||||
|
log.Println("--- Client App Started ---")
|
||||||
|
}
|
||||||
|
return logFile
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if err := singleinstance.Acquire(); err != nil {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
lf := initLogging()
|
||||||
|
if lf != nil {
|
||||||
|
defer lf.Close()
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
|
||||||
|
appMenu := menu.NewMenu()
|
||||||
|
fileMenu := appMenu.AddSubmenu("Simple Care")
|
||||||
|
fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) {
|
||||||
|
app.ReturnToDashboard()
|
||||||
|
})
|
||||||
|
fileMenu.AddText("Làm mới trang (F5)", keys.Key("f5"), func(_ *menu.CallbackData) {
|
||||||
|
app.ReloadExamPage()
|
||||||
|
})
|
||||||
|
fileMenu.AddText("Xóa cache trình duyệt", keys.CmdOrCtrl("Delete"), func(_ *menu.CallbackData) {
|
||||||
|
app.ClearExamBrowserCache()
|
||||||
|
})
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
Title: "Simple Care — Rikkei Education",
|
Title: "Simple Care v1.3 — Rikkei Education",
|
||||||
Width: 1024,
|
Width: 1024,
|
||||||
Height: 768,
|
Height: 768,
|
||||||
|
Menu: appMenu,
|
||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||||||
|
return app.HandleBeforeClose()
|
||||||
|
},
|
||||||
|
Windows: &windows.Options{
|
||||||
|
WebviewUserDataPath: app.webviewDataPath,
|
||||||
|
},
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
app,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
"name": "Simple Care by Rikkei Edu",
|
"name": "Simple Care by Rikkei Edu",
|
||||||
"outputfilename": "simple_care_v1.0",
|
"outputfilename": "simple_care_v1.3",
|
||||||
"frontend:install": "npm install",
|
"frontend:install": "npm install",
|
||||||
"frontend:build": "npm run build",
|
"frontend:build": "npm run build",
|
||||||
"frontend:dev:watcher": "npm run dev",
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
|||||||
405
landing_page/index.html
Normal file
405
landing_page/index.html
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="vi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Simple Care - Tải Ứng Dụng & Hướng Dẫn Hỗ Trợ Sinh Viên</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #bb2126;
|
||||||
|
--primary-hover: #9c1b1f;
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #1e293b;
|
||||||
|
--text-sub: #475569;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--radius: 8px;
|
||||||
|
--transition: all 0.2s ease;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-sub);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: var(--text-sub);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: var(--text-sub);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guides section styles */
|
||||||
|
.guide-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-link:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-link-btn {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-link-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container, .error-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 4rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-sub);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Simple Care</h1>
|
||||||
|
<p class="subtitle">Cổng tải ứng dụng và tài liệu hướng dẫn học tập, thi cử dành cho sinh viên</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Applications section -->
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
|
||||||
|
<path d="M9 22V12h6v10M2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
Ứng dụng hỗ trợ
|
||||||
|
</h2>
|
||||||
|
<div id="apps-loading" class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Đang tải danh sách ứng dụng...</p>
|
||||||
|
</div>
|
||||||
|
<div id="apps-error" class="error-container" style="display: none;"></div>
|
||||||
|
<div id="apps-list" class="grid" style="display: none;"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Guides section -->
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
Hướng dẫn & Tài liệu
|
||||||
|
</h2>
|
||||||
|
<div id="guides-loading" class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Đang tải danh sách tài liệu...</p>
|
||||||
|
</div>
|
||||||
|
<div id="guides-error" class="error-container" style="display: none;"></div>
|
||||||
|
<div id="guides-list" class="guide-list" style="display: none;"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2026 Simple Care. Bản quyền thuộc Rikkei Academy.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Platforms coloring/badges
|
||||||
|
function getPlatformIcon(platform) {
|
||||||
|
const p = platform.toLowerCase();
|
||||||
|
if (p.includes('win')) {
|
||||||
|
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="color: #0078d7"><path d="M0 3.449L9.75 2.1v9.45H0V3.449zM0 12.45h9.75v9.45L0 20.551v-8.1zM10.8 1.95L24 0v11.55H10.8V1.95zM10.8 12.45H24v11.55l-13.2-1.95v-9.6z"/></svg>`;
|
||||||
|
}
|
||||||
|
if (p.includes('mac') || p.includes('apple') || p.includes('ios')) {
|
||||||
|
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="color: #555"><path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 4.17c.66-.81 1.11-1.93.99-3.05-1 .04-2.22.67-2.94 1.51-.62.73-1.16 1.87-1.01 2.97 1.12.09 2.27-.58 2.96-1.43z"/></svg>`;
|
||||||
|
}
|
||||||
|
if (p.includes('linux')) {
|
||||||
|
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style="color: #FCC624"><path d="M12 2c-3.3 0-6 2.7-6 6 0 2.2.8 4.2 2.2 5.6C6.5 15.6 5 18.5 5 22h14c0-3.5-1.5-6.4-3.2-8.4 1.4-1.4 2.2-3.4 2.2-5.6 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4 0 .9-.3 1.7-.8 2.3-.3.4-.7.7-1.2.9-.6.2-1.3.3-2 .3s-1.4-.1-2-.3c-.5-.2-.9-.5-1.2-.9-.5-.6-.8-1.4-.8-2.3 0-2.2 1.8-4 4-4z"/></svg>`;
|
||||||
|
}
|
||||||
|
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: var(--primary)"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = 'https://sv.rikkeiraia.org';
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
const loader = document.getElementById('apps-loading');
|
||||||
|
const errorDiv = document.getElementById('apps-error');
|
||||||
|
const listDiv = document.getElementById('apps-list');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${host}/api/public/app-downloads`);
|
||||||
|
if (!response.ok) throw new Error('Không thể lấy danh sách ứng dụng');
|
||||||
|
|
||||||
|
const res = await response.json();
|
||||||
|
const data = res.data || [];
|
||||||
|
|
||||||
|
loader.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
errorDiv.innerText = 'Không có ứng dụng nào khả dụng để tải về.';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listDiv.innerHTML = data.map(app => `
|
||||||
|
<div class="card">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">
|
||||||
|
${getPlatformIcon(app.platform)}
|
||||||
|
${app.name}
|
||||||
|
</div>
|
||||||
|
<div class="card-desc">${app.description || 'Hỗ trợ quá trình kiểm tra và học tập.'}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span class="platform-badge">${app.platform === 'macOS' ? 'MacOS' : app.platform}</span>
|
||||||
|
<a href="${app.downloadUrl}" target="_blank" rel="noopener noreferrer" class="btn">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Tải về
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
listDiv.style.display = 'grid';
|
||||||
|
} catch (err) {
|
||||||
|
loader.style.display = 'none';
|
||||||
|
errorDiv.innerText = 'Lỗi kết nối máy chủ. Vui lòng thử lại sau.';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGuides() {
|
||||||
|
const loader = document.getElementById('guides-loading');
|
||||||
|
const errorDiv = document.getElementById('guides-error');
|
||||||
|
const listDiv = document.getElementById('guides-list');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${host}/api/public/app-guides`);
|
||||||
|
if (!response.ok) throw new Error('Không thể lấy danh sách hướng dẫn');
|
||||||
|
|
||||||
|
const res = await response.json();
|
||||||
|
const data = res.data || [];
|
||||||
|
|
||||||
|
loader.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
errorDiv.innerText = 'Không có tài liệu hoặc video hướng dẫn nào.';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listDiv.innerHTML = data.map(guide => {
|
||||||
|
const isVideo = guide.url.toLowerCase().includes('youtube.com') || guide.url.toLowerCase().includes('youtu.be') || guide.url.toLowerCase().includes('drive.google.com/file');
|
||||||
|
const icon = isVideo
|
||||||
|
? `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: #ea4335"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>`
|
||||||
|
: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="color: #4285f4"><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"/></svg>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="guide-item">
|
||||||
|
<a href="${guide.url}" target="_blank" rel="noopener noreferrer" class="guide-link">
|
||||||
|
${icon}
|
||||||
|
${guide.title}
|
||||||
|
</a>
|
||||||
|
<a href="${guide.url}" target="_blank" rel="noopener noreferrer" class="guide-link-btn">Xem →</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
listDiv.style.display = 'flex';
|
||||||
|
} catch (err) {
|
||||||
|
loader.style.display = 'none';
|
||||||
|
errorDiv.innerText = 'Lỗi kết nối máy chủ. Vui lòng thử lại sau.';
|
||||||
|
errorDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run
|
||||||
|
loadApps();
|
||||||
|
loadGuides();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
833
management/package-lock.json
generated
833
management/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
|
|||||||
1
management/src/.env
Normal file
1
management/src/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE=https://sv.rikkeiraia.org/api
|
||||||
@@ -1,86 +1,184 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useCallback, useState } from 'react';
|
||||||
|
import { apiMyClasses } from './api';
|
||||||
import { DashboardTab } from './components/DashboardTab';
|
import { DashboardTab } from './components/DashboardTab';
|
||||||
import { ClassesTab } from './components/ClassesTab';
|
import { ClassesTab } from './components/ClassesTab';
|
||||||
import { StudentsTab } from './components/StudentsTab';
|
import { StudentsTab } from './components/StudentsTab';
|
||||||
import { LearningTab } from './components/LearningTab';
|
import { LearningTab } from './components/LearningTab';
|
||||||
import { ExamsTab } from './components/ExamsTab';
|
import { ExamsTab } from './components/ExamsTab';
|
||||||
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
||||||
import { NetworkTab } from './components/NetworkTab';
|
import { SystemTab } from './components/SystemTab';
|
||||||
import { EmailDomainsTab, MyAccountTab } from './components/AccountsTab';
|
import { MyAccountTab } from './components/AccountsTab';
|
||||||
|
import { StudentAffairsTab } from './components/StudentAffairsTab';
|
||||||
|
import { ApplicationsTab } from './components/ApplicationsTab';
|
||||||
import { ChatWidget } from './components/ChatWidget';
|
import { ChatWidget } from './components/ChatWidget';
|
||||||
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
||||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||||
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
||||||
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
|
import {
|
||||||
|
goBack,
|
||||||
|
navigate,
|
||||||
|
navigateSystem,
|
||||||
|
parseRoute,
|
||||||
|
pushNav,
|
||||||
|
SYSTEM_SECTION_LABELS,
|
||||||
|
TAB_LABELS,
|
||||||
|
type SystemSection,
|
||||||
|
type TabId,
|
||||||
|
} from './navigation';
|
||||||
import { useAuth } from './auth/AuthContext';
|
import { useAuth } from './auth/AuthContext';
|
||||||
|
|
||||||
const IconDashboard = () => (
|
const IconDashboard = () => (
|
||||||
<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>
|
||||||
<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="3" width="7" height="7" rx="1.5" />
|
||||||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconClass = () => (
|
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" />
|
<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" />
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconStudent = () => (
|
const IconStudent = () => (
|
||||||
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="9" cy="7" r="4" />
|
<circle cx="9" cy="7" r="3.5" />
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconLearning = () => (
|
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="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" />
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconExam = () => (
|
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" />
|
<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" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconNetwork = () => (
|
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>
|
||||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
<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" />
|
||||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
|
||||||
<circle cx="12" cy="20" r="1" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconEmail = () => (
|
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>
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
<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" />
|
||||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const IconApplications = () => (
|
||||||
|
<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 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="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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SYSTEM_NAV: { section: SystemSection }[] = [
|
||||||
|
{ section: 'organization' },
|
||||||
|
{ section: 'network' },
|
||||||
|
{ section: 'templates' },
|
||||||
|
{ section: 'seating' },
|
||||||
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { staff, logout } = useAuth();
|
const { staff, logout } = useAuth();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||||
|
return localStorage.getItem('sc_sidebar_collapsed') === 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSidebarCollapse = () => {
|
||||||
|
// On mobile (<=900px), just close the sidebar overlay
|
||||||
|
if (window.innerWidth <= 900) {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// On desktop, toggle collapsed state
|
||||||
|
const nextVal = !sidebarCollapsed;
|
||||||
|
setSidebarCollapsed(nextVal);
|
||||||
|
localStorage.setItem('sc_sidebar_collapsed', String(nextVal));
|
||||||
|
};
|
||||||
|
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(() => {
|
||||||
|
refreshMyClasses();
|
||||||
|
}, [refreshMyClasses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('my-classes-updated', refreshMyClasses);
|
||||||
|
return () => window.removeEventListener('my-classes-updated', refreshMyClasses);
|
||||||
|
}, [refreshMyClasses]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = parseRoute();
|
const initial = parseRoute();
|
||||||
if (initial.classId || initial.examId) {
|
if (initial.classId || initial.examId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] });
|
const label =
|
||||||
|
initial.tab === 'system'
|
||||||
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[initial.systemSection]}`
|
||||||
|
: TAB_LABELS[initial.tab];
|
||||||
|
pushNav({
|
||||||
|
kind: 'tab',
|
||||||
|
tab: initial.tab,
|
||||||
|
systemSection: initial.tab === 'system' ? initial.systemSection : undefined,
|
||||||
|
label,
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}, [route.tab, route.classId, route.examId, route.systemSection]);
|
||||||
|
|
||||||
const setActiveTab = (tab: string) => {
|
const setActiveTab = (tab: string) => {
|
||||||
navigate(tab as TabId);
|
navigate(tab as TabId);
|
||||||
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackFromWorkspace = () => {
|
const handleBackFromWorkspace = () => {
|
||||||
@@ -89,9 +187,41 @@ function App() {
|
|||||||
|
|
||||||
const inWorkspace = Boolean(route.classId || route.examId);
|
const inWorkspace = Boolean(route.classId || route.examId);
|
||||||
|
|
||||||
|
const classesActive = route.tab === 'classes';
|
||||||
|
const learningActive = route.tab === 'learning';
|
||||||
|
const examsActive = route.tab === 'exams' || route.examId != null;
|
||||||
|
const systemActive = route.tab === 'system' && !inWorkspace;
|
||||||
|
|
||||||
|
const goSystem = (section: SystemSection) => {
|
||||||
|
navigateSystem(section);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navBtn = (active: boolean) => `nav-btn${active ? ' active' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="sidebar">
|
{!sidebarOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-menu-btn"
|
||||||
|
aria-label="Mở menu"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<IconMenu />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-backdrop"
|
||||||
|
aria-label="Đóng menu"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}${sidebarCollapsed ? ' sidebar--collapsed' : ''}`}>
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
||||||
<div className="brand-text">
|
<div className="brand-text">
|
||||||
@@ -100,78 +230,134 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav>
|
<nav className="sidebar-nav" aria-label="Menu chính">
|
||||||
<ul className="nav-links">
|
<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">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'dashboard' && !inWorkspace ? 'active' : ''}`}
|
type="button"
|
||||||
onClick={() => navigate('dashboard')}
|
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
|
||||||
|
onClick={() => setActiveTab('dashboard')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconDashboard /></span>
|
<span className="nav-icon"><IconDashboard /></span>
|
||||||
Tổng quan
|
<span className="nav-label">Tổng quan & Tài liệu</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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">
|
<li className="nav-item">
|
||||||
<button
|
<button type="button" className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
||||||
className={`nav-btn ${route.tab === 'classes' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('classes')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconClass /></span>
|
<span className="nav-icon"><IconClass /></span>
|
||||||
Lớp học
|
<span className="nav-label">Lớp học</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'students' && !inWorkspace ? 'active' : ''}`}
|
type="button"
|
||||||
onClick={() => navigate('students')}
|
className={navBtn(route.tab === 'students' && !inWorkspace)}
|
||||||
|
onClick={() => setActiveTab('students')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconStudent /></span>
|
<span className="nav-icon"><IconStudent /></span>
|
||||||
Sinh viên
|
<span className="nav-label">Sinh viên</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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">
|
<li className="nav-item">
|
||||||
<button
|
<button type="button" className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
||||||
className={`nav-btn ${route.tab === 'learning' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('learning')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconLearning /></span>
|
<span className="nav-icon"><IconLearning /></span>
|
||||||
Giám sát & Lịch học
|
<span className="nav-label">Phòng học</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button type="button" className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
||||||
className={`nav-btn ${route.tab === 'exams' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('exams')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconExam /></span>
|
<span className="nav-icon"><IconExam /></span>
|
||||||
Phòng thi
|
<span className="nav-label">Phòng thi</span>
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className="nav-item">
|
|
||||||
<button
|
|
||||||
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('network')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconNetwork /></span>
|
|
||||||
Quản lý mạng
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<div className="nav-header">Hệ thống</div>
|
{myClasses.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><IconMyClass /></span>
|
||||||
|
<span className="nav-label">{c.code || c.name}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<li className="nav-section" aria-hidden="true">
|
||||||
|
<span className="nav-header">Công tác sinh viên</span>
|
||||||
|
</li>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'email-domains' && !inWorkspace ? 'active' : ''}`}
|
type="button"
|
||||||
onClick={() => navigate('email-domains')}
|
className={navBtn(route.tab === 'student-affairs' && !inWorkspace)}
|
||||||
|
onClick={() => setActiveTab('student-affairs')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconEmail /></span>
|
<span className="nav-icon"><IconStudentAffairs /></span>
|
||||||
Đuôi email
|
<span className="nav-label">Công tác sinh viên</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={navBtn(route.tab === 'applications' && !inWorkspace)}
|
||||||
|
onClick={() => setActiveTab('applications')}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><IconApplications /></span>
|
||||||
|
<span className="nav-label">Ứng dụng</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={navBtn(systemActive && route.systemSection === section)}
|
||||||
|
onClick={() => goSystem(section)}
|
||||||
|
>
|
||||||
|
<span className="nav-label">{SYSTEM_SECTION_LABELS[section]}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -179,21 +365,47 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
|
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
|
||||||
onClick={() => navigate('profile')}
|
onClick={() => setActiveTab('profile')}
|
||||||
title="Tài khoản của tôi"
|
title="Tài khoản của tôi"
|
||||||
>
|
>
|
||||||
<span className="sidebar-user-name">
|
<span className="sidebar-user-avatar" aria-hidden>
|
||||||
{staff?.fullName?.trim() || staff?.email?.split('@')[0] || 'Tài khoản'}
|
{(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>
|
</span>
|
||||||
<span className="sidebar-user-email">{staff?.email}</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="link-btn" onClick={logout}>
|
<button type="button" className="sidebar-logout" onClick={logout}>
|
||||||
|
<IconLogout />
|
||||||
Đăng xuất
|
Đăng xuất
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-edge-toggle"
|
||||||
|
onClick={toggleSidebarCollapse}
|
||||||
|
title={sidebarCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className={`main-content${sidebarCollapsed ? ' main-content--full' : ''}`}>
|
||||||
<div className="page-viewport">
|
<div className="page-viewport">
|
||||||
{route.classId ? (
|
{route.classId ? (
|
||||||
<ClassWorkspace
|
<ClassWorkspace
|
||||||
@@ -213,9 +425,10 @@ function App() {
|
|||||||
{route.tab === 'students' && <StudentsTab />}
|
{route.tab === 'students' && <StudentsTab />}
|
||||||
{route.tab === 'learning' && <LearningTab />}
|
{route.tab === 'learning' && <LearningTab />}
|
||||||
{route.tab === 'exams' && <ExamsTab />}
|
{route.tab === 'exams' && <ExamsTab />}
|
||||||
{route.tab === 'network' && <NetworkTab />}
|
{route.tab === 'system' && <SystemTab />}
|
||||||
{route.tab === 'email-domains' && <EmailDomainsTab />}
|
|
||||||
{route.tab === 'profile' && <MyAccountTab />}
|
{route.tab === 'profile' && <MyAccountTab />}
|
||||||
|
{route.tab === 'student-affairs' && <StudentAffairsTab />}
|
||||||
|
{route.tab === 'applications' && <ApplicationsTab />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
const API_BASE = 'http://127.0.0.1:8080/api';
|
export const API_BASE = import.meta.env.VITE_API_BASE || 'https://sv.rikkeiraia.org/api';
|
||||||
|
|
||||||
|
export function getWsUrl(path: string): string {
|
||||||
|
try {
|
||||||
|
if (API_BASE.startsWith('http://') || API_BASE.startsWith('https://')) {
|
||||||
|
const url = new URL(API_BASE);
|
||||||
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${url.host}${path}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${protocol}//${window.location.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
return localStorage.getItem('sc_staff_token');
|
return localStorage.getItem('sc_staff_token');
|
||||||
@@ -81,6 +96,63 @@ export const apiAuth = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface GitHubStatus {
|
||||||
|
connected: boolean;
|
||||||
|
githubLogin?: string;
|
||||||
|
connectedAt?: string;
|
||||||
|
scope?: string;
|
||||||
|
canDeleteRepos?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiGitHub = {
|
||||||
|
status: async (): Promise<GitHubStatus> => {
|
||||||
|
const res = await staffFetch('/auth/github/status');
|
||||||
|
if (!res.ok) await parseError(res, 'Không tải trạng thái GitHub');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
authorizeUrl: async (): Promise<{ authorizeUrl: string }> => {
|
||||||
|
const res = await staffFetch('/auth/github/authorize');
|
||||||
|
if (!res.ok) await parseError(res, 'Không bắt đầu OAuth GitHub');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
disconnect: async () => {
|
||||||
|
const res = await staffFetch('/auth/github', { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Ngắt kết nối GitHub thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface GitHubRepoItem {
|
||||||
|
key: string;
|
||||||
|
ownerLogin: string;
|
||||||
|
repoName: string;
|
||||||
|
repoHtmlUrl: string;
|
||||||
|
private?: boolean;
|
||||||
|
examRoomId?: number;
|
||||||
|
examRoomName?: string;
|
||||||
|
studentLabel?: string;
|
||||||
|
publishMode?: 'room' | 'student' | '';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
fromSimpleCare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiGitHubRepos = {
|
||||||
|
list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => {
|
||||||
|
const res = await staffFetch('/auth/github/repos');
|
||||||
|
if (!res.ok) await parseError(res, 'Không tải danh sách repo');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
bulkDelete: async (repos: string[]) => {
|
||||||
|
const res = await staffFetch('/auth/github/repos/bulk-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ repos }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa repo thất bại');
|
||||||
|
return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface EmailDomainItem {
|
export interface EmailDomainItem {
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -197,6 +269,32 @@ export interface StatsResponse {
|
|||||||
totalStudents: number;
|
totalStudents: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuideLinkItem {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppDownloadItem {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGuideItem {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -283,6 +381,32 @@ export interface StudentSessionLogItem {
|
|||||||
lastActiveAt?: string;
|
lastActiveAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StudentViolationItem {
|
||||||
|
id: number;
|
||||||
|
studentRkId: number;
|
||||||
|
studentCode: string;
|
||||||
|
fullName: string;
|
||||||
|
classRkId: number;
|
||||||
|
examRoomId?: number;
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
monitorMode: string;
|
||||||
|
clientAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VIOLATION_KIND_OPTIONS = [
|
||||||
|
{ value: '', label: 'Tất cả loại' },
|
||||||
|
{ 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' },
|
||||||
|
{ value: 'session_change', label: 'Khóa / đổi phiên' },
|
||||||
|
{ value: 'virtual_desktop', label: 'Desktop ảo' },
|
||||||
|
{ value: 'wifi', label: 'WiFi trái phép' },
|
||||||
|
{ value: 'guard', label: 'Vi phạm môi trường (cũ)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getStats: async (): Promise<StatsResponse> => {
|
getStats: async (): Promise<StatsResponse> => {
|
||||||
const res = await staffFetch('/stats');
|
const res = await staffFetch('/stats');
|
||||||
@@ -290,6 +414,87 @@ export const api = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listGuideLinks: async (): Promise<{ data: GuideLinkItem[] }> => {
|
||||||
|
const res = await staffFetch('/guide-links');
|
||||||
|
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
createGuideLink: async (title: string, url: string): Promise<{ data: GuideLinkItem }> => {
|
||||||
|
const res = await staffFetch('/guide-links', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title, url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Thêm tài liệu thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
updateGuideLink: async (id: number, title: string, url: string): Promise<{ data: GuideLinkItem }> => {
|
||||||
|
const res = await staffFetch(`/guide-links/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ title, url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Cập nhật tài liệu thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
deleteGuideLink: async (id: number): Promise<{ ok: boolean }> => {
|
||||||
|
const res = await staffFetch(`/guide-links/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa tài liệu thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
listAppDownloads: async (): Promise<{ data: AppDownloadItem[] }> => {
|
||||||
|
const res = await staffFetch('/app-downloads');
|
||||||
|
if (!res.ok) throw new Error('Không thể tải danh sách ứng dụng');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
createAppDownload: async (name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => {
|
||||||
|
const res = await staffFetch('/app-downloads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, description, downloadUrl, platform }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Thêm ứng dụng thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
updateAppDownload: async (id: number, name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => {
|
||||||
|
const res = await staffFetch(`/app-downloads/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name, description, downloadUrl, platform }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Cập nhật ứng dụng thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
deleteAppDownload: async (id: number): Promise<{ ok: boolean }> => {
|
||||||
|
const res = await staffFetch(`/app-downloads/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa ứng dụng thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
listAppGuides: async (): Promise<{ data: AppGuideItem[] }> => {
|
||||||
|
const res = await staffFetch('/app-guides');
|
||||||
|
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu hướng dẫn');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
createAppGuide: async (title: string, url: string): Promise<{ data: AppGuideItem }> => {
|
||||||
|
const res = await staffFetch('/app-guides', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title, url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Thêm tài liệu hướng dẫn thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
updateAppGuide: async (id: number, title: string, url: string): Promise<{ data: AppGuideItem }> => {
|
||||||
|
const res = await staffFetch(`/app-guides/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ title, url }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Cập nhật tài liệu hướng dẫn thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
deleteAppGuide: async (id: number): Promise<{ ok: boolean }> => {
|
||||||
|
const res = await staffFetch(`/app-guides/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Xóa tài liệu hướng dẫn thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
getClasses: async (params: {
|
getClasses: async (params: {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -445,6 +650,38 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AppTemplateItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiAppTemplates = {
|
||||||
|
list: async (): Promise<{ data: AppTemplateItem[] }> => {
|
||||||
|
const res = await staffFetch('/app-templates');
|
||||||
|
if (!res.ok) throw new Error('Không tải được khung ứng dụng');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
create: async (body: { name: string; description?: string; keywords: string }) => {
|
||||||
|
const res = await staffFetch('/app-templates', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
if (!res.ok) await parseError(res, 'Không tạo được khung');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
update: async (id: number, body: { name?: string; description?: string; keywords?: string }) => {
|
||||||
|
const res = await staffFetch(`/app-templates/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
|
||||||
|
if (!res.ok) await parseError(res, 'Không cập nhật được khung');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
delete: async (id: number) => {
|
||||||
|
const res = await staffFetch(`/app-templates/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) await parseError(res, 'Không xóa được khung');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface WifiPoolItem {
|
export interface WifiPoolItem {
|
||||||
id: number;
|
id: number;
|
||||||
ssid: string;
|
ssid: string;
|
||||||
@@ -505,12 +742,51 @@ export const apiFetchClassSessionLogs = async (
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiFetchClassViolations = async (
|
||||||
|
rkId: number,
|
||||||
|
date: string,
|
||||||
|
kind = ''
|
||||||
|
): Promise<{ data: StudentViolationItem[]; date: string }> => {
|
||||||
|
const params = new URLSearchParams({ date });
|
||||||
|
if (kind) params.set('kind', kind);
|
||||||
|
const res = await staffFetch(`/classes/${rkId}/violations?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch class violations');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiFetchExamViolations = async (
|
||||||
|
examId: number,
|
||||||
|
date: string,
|
||||||
|
kind = ''
|
||||||
|
): Promise<{ data: StudentViolationItem[]; date: string }> => {
|
||||||
|
const params = new URLSearchParams({ date });
|
||||||
|
if (kind) params.set('kind', kind);
|
||||||
|
const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch exam violations');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
||||||
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch online students list');
|
if (!res.ok) throw new Error('Failed to fetch online students list');
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ScheduleConflictItem {
|
||||||
|
studentRkId: number;
|
||||||
|
fullName: string;
|
||||||
|
studentCode: string;
|
||||||
|
conflictClassRkId: number;
|
||||||
|
conflictClassName: string;
|
||||||
|
conflictClassCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiFetchScheduleConflicts = async (rkId: number): Promise<{ conflicts: ScheduleConflictItem[]; ok: boolean }> => {
|
||||||
|
const res = await staffFetch(`/classes/${rkId}/schedule-conflicts`);
|
||||||
|
if (!res.ok) throw new Error('Failed to check schedule conflicts');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
export const apiFetchClassCourses = async (rkId: number): Promise<{
|
export const apiFetchClassCourses = async (rkId: number): Promise<{
|
||||||
data: ClassCourseItem[];
|
data: ClassCourseItem[];
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -560,6 +836,21 @@ export const apiUpdateAttendanceStatus = async (
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiUpdateAttendanceBulkStatus = async (
|
||||||
|
rkId: number,
|
||||||
|
payload: { date: string; period: number; studentRkIds: number[]; status: number }
|
||||||
|
) => {
|
||||||
|
const res = await staffFetch(`/classes/${rkId}/attendance/bulk-status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to update attendance status');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
|
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
|
||||||
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
|
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -572,6 +863,55 @@ export const apiPushAttendanceQLDT = async (rkId: number, date: string, period:
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface LeaveRequestItem {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
note: string;
|
||||||
|
reasonImage?: string;
|
||||||
|
rejectReason?: string | null;
|
||||||
|
period: number;
|
||||||
|
status: string; // 'Đang chờ', 'Phê duyệt', 'Từ chối'
|
||||||
|
approverId?: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
student: {
|
||||||
|
id: number;
|
||||||
|
studentCode: string;
|
||||||
|
fullName: string;
|
||||||
|
phone?: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiFetchLeaveRequests = async (
|
||||||
|
classId: number,
|
||||||
|
courseId: number,
|
||||||
|
date: string
|
||||||
|
): Promise<LeaveRequestItem[]> => {
|
||||||
|
const res = await staffFetch(`/classes/${classId}/leave-requests?courseId=${courseId}&date=${date}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to fetch leave requests');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiUpdateLeaveStatus = async (
|
||||||
|
classId: number,
|
||||||
|
leaveId: number,
|
||||||
|
payload: { status: string; studentRkId: number; date: string; period: number }
|
||||||
|
): Promise<{ ok: boolean; message: string }> => {
|
||||||
|
const res = await staffFetch(`/classes/${classId}/leave-requests/${leaveId}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Failed to update leave status');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
async function staffUpload(path: string, form: FormData): Promise<Response> {
|
async function staffUpload(path: string, form: FormData): Promise<Response> {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -586,10 +926,14 @@ export interface ExamRoomItem {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
allowedApps: string;
|
allowedApps: string;
|
||||||
quizUrl: string;
|
quizUrl: string;
|
||||||
|
gitRepoUrl?: string;
|
||||||
|
gitBranch?: string;
|
||||||
|
gitPublishUrl?: string;
|
||||||
status: 'draft' | 'ready' | 'ended' | 'cancelled';
|
status: 'draft' | 'ready' | 'ended' | 'cancelled';
|
||||||
studentCount: number;
|
studentCount: number;
|
||||||
paperCount: number;
|
paperCount: number;
|
||||||
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
||||||
|
createdByStaffId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamPaperResource {
|
export interface ExamPaperResource {
|
||||||
@@ -630,11 +974,21 @@ export interface ExamSubmission {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
studentCode: string;
|
studentCode: string;
|
||||||
|
gitRepoUrl?: string;
|
||||||
|
gitPublishUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
||||||
|
const bin = atob(b64);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out.buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiExam = {
|
export const apiExam = {
|
||||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
list: async (opts?: { mine?: boolean }): Promise<{ data: ExamRoomItem[] }> => {
|
||||||
const res = await staffFetch('/exam-rooms');
|
const q = opts?.mine ? '?mine=1' : '';
|
||||||
|
const res = await staffFetch(`/exam-rooms${q}`);
|
||||||
if (!res.ok) await parseError(res, 'Failed');
|
if (!res.ok) await parseError(res, 'Failed');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
@@ -656,6 +1010,7 @@ export const apiExam = {
|
|||||||
canPublish: boolean;
|
canPublish: boolean;
|
||||||
canUnpublish: boolean;
|
canUnpublish: boolean;
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
|
canExtend?: boolean;
|
||||||
}>;
|
}>;
|
||||||
},
|
},
|
||||||
publish: async (id: number) => {
|
publish: async (id: number) => {
|
||||||
@@ -679,6 +1034,14 @@ export const apiExam = {
|
|||||||
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
||||||
return res.json();
|
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 }>) => {
|
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) });
|
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');
|
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
|
||||||
@@ -736,11 +1099,51 @@ export const apiExam = {
|
|||||||
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
|
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
fetchPaperPdfBytes: async (examId: number, paperId: number): Promise<ArrayBuffer> => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/view`);
|
||||||
|
if (!res.ok) await parseError(res, 'Không mở được đề PDF');
|
||||||
|
const json = await res.json() as { data: string };
|
||||||
|
if (!json.data) throw new Error('Server không trả dữ liệu PDF');
|
||||||
|
return decodeBase64ToArrayBuffer(json.data);
|
||||||
|
},
|
||||||
|
fetchPaperResourceBytes: async (examId: number, paperId: number, resourceId: number): Promise<ArrayBuffer> => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/resources/${resourceId}/view`);
|
||||||
|
if (!res.ok) await parseError(res, 'Không tải tài nguyên');
|
||||||
|
const json = await res.json() as { data: string };
|
||||||
|
if (!json.data) throw new Error('Server không trả dữ liệu file');
|
||||||
|
return decodeBase64ToArrayBuffer(json.data);
|
||||||
|
},
|
||||||
|
downloadPaperFile: async (examId: number, paperId: number, opts?: { resourceId?: number; fileName?: string }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts?.resourceId) {
|
||||||
|
params.set('kind', 'resource');
|
||||||
|
params.set('fileId', String(opts.resourceId));
|
||||||
|
} else {
|
||||||
|
params.set('kind', 'pdf');
|
||||||
|
}
|
||||||
|
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/download?${params}`);
|
||||||
|
if (!res.ok) await parseError(res, 'Tải file thất bại');
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const url = URL.createObjectURL(new Blob([buf]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = opts?.fileName || 'de-thi.pdf';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
assignRandom: async (id: number) => {
|
assignRandom: async (id: number) => {
|
||||||
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
|
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
|
||||||
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
||||||
return res.json() as Promise<{ assigned: number; packages: number }>;
|
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[] }) => {
|
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
|
||||||
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
|
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');
|
if (!res.ok) await parseError(res, 'Gửi gói đề thất bại');
|
||||||
@@ -752,4 +1155,128 @@ export const apiExam = {
|
|||||||
return res.json() as Promise<{ data: ExamSubmission[] }>;
|
return res.json() as Promise<{ data: ExamSubmission[] }>;
|
||||||
},
|
},
|
||||||
downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`,
|
downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`,
|
||||||
|
downloadAllSubmissions: async (id: number, fallbackName: string) => {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await staffFetch(`/exam-rooms/${id}/submissions/download-all`);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Không kết nối được server (Failed to fetch). Kiểm tra server đang chạy và thử lại.');
|
||||||
|
}
|
||||||
|
if (!res.ok) await parseError(res, 'Tải bài nộp gộp thất bại');
|
||||||
|
const blob = await res.blob();
|
||||||
|
if (!blob.size) throw new Error('File ZIP trống — có thể bài nộp trên server bị thiếu file');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fallbackName.replace(/[<>:"/\\|?*]+/g, '_')}_bai_nop.zip`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
saveGitSettings: async (id: number, payload: { gitRepoUrl?: string; gitBranch?: string }) => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${id}/git-settings`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||||
|
if (!res.ok) await parseError(res, 'Lưu cấu hình Git thất bại');
|
||||||
|
return res.json() as Promise<{ gitRepoUrl: string; gitBranch: string; gitPublishUrl?: string }>;
|
||||||
|
},
|
||||||
|
publishSubmissionsGit: async (id: number) => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] }));
|
||||||
|
const msg = errData.error || 'Đẩy lên Git thất bại';
|
||||||
|
if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>;
|
||||||
|
},
|
||||||
|
publishSubmissionsGitStudents: async (id: number) => {
|
||||||
|
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] }));
|
||||||
|
const msg = errData.error || 'Đẩy repo từng SV thất bại';
|
||||||
|
if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
published: number;
|
||||||
|
openUrl?: string;
|
||||||
|
message: string;
|
||||||
|
warnings?: string[];
|
||||||
|
studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[];
|
||||||
|
}>;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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');
|
||||||
|
if (!res.ok) await parseError(res, 'Không tải được token QLĐT');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
saveToken: async (token: string): Promise<{ ok: boolean; message: string }> => {
|
||||||
|
const res = await staffFetch('/qldt-token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
if (!res.ok) await parseError(res, 'Lưu token thất bại');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||||
|
import { GitHubReposPanel } from './GitHubReposPanel';
|
||||||
|
import { OrganizationSection } from './OrganizationSection';
|
||||||
|
import { apiQldt } from '../api';
|
||||||
|
|
||||||
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||||
const { changePassword, logout } = useAuth();
|
const { changePassword, logout } = useAuth();
|
||||||
@@ -65,84 +68,69 @@ export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmailDomainsTab() {
|
export function EmailDomainsTab() {
|
||||||
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
|
||||||
const [newDomain, setNewDomain] = useState('');
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
const res = await apiAdmin.listEmailDomains();
|
|
||||||
setDomains(res.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load().catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addDomain = async () => {
|
|
||||||
if (!newDomain.trim()) return;
|
|
||||||
await apiAdmin.addEmailDomain(newDomain.trim());
|
|
||||||
setNewDomain('');
|
|
||||||
await load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDomain = async (id: number) => {
|
|
||||||
if (!confirm('Xóa đuôi email này?')) return;
|
|
||||||
await apiAdmin.deleteEmailDomain(id);
|
|
||||||
await load();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="tab-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1 className="page-title">Đuôi email</h1>
|
<h1 className="page-title">Quản lý tổ chức</h1>
|
||||||
<p className="page-desc">Cấu hình đuôi email được phép đăng ký và đăng nhập.</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
<OrganizationSection />
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
|
||||||
<h2 className="section-title">Đuôi email được phép</h2>
|
|
||||||
<p className="page-desc" style={{ marginTop: 0 }}>
|
|
||||||
Nếu chưa có bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi để giới hạn truy cập.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
||||||
<input
|
|
||||||
placeholder="vd: rikkeiacademy.com"
|
|
||||||
value={newDomain}
|
|
||||||
onChange={(e) => setNewDomain(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={addDomain}>Thêm</button>
|
|
||||||
</div>
|
|
||||||
<ul className="domain-list">
|
|
||||||
{domains.length === 0 && <li className="domain-empty">Chưa cấu hình — chấp nhận tất cả đuôi email</li>}
|
|
||||||
{domains.map((d) => (
|
|
||||||
<li key={d.id} className="domain-item">
|
|
||||||
<span>@{d.domain}</span>
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>Xóa</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MyAccountTab() {
|
export function MyAccountTab() {
|
||||||
const { staff, changePassword } = useAuth();
|
const { staff, changePassword } = useAuth();
|
||||||
|
const gh = useGitHubConnection();
|
||||||
const [oldPw, setOldPw] = useState('');
|
const [oldPw, setOldPw] = useState('');
|
||||||
const [newPw, setNewPw] = useState('');
|
const [newPw, setNewPw] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [pwMsg, setPwMsg] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [pwErr, setPwErr] = useState('');
|
||||||
|
|
||||||
|
// Cấu hình QLĐT Token (Chỉ cho phuocntb@rikkeiacademy.com)
|
||||||
|
const isQldtAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||||
|
const [qldtToken, setQldtToken] = useState('');
|
||||||
|
const [qldtMsg, setQldtMsg] = useState('');
|
||||||
|
const [qldtErr, setQldtErr] = useState('');
|
||||||
|
const [loadingQldt, setLoadingQldt] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isQldtAdmin) return;
|
||||||
|
setLoadingQldt(true);
|
||||||
|
apiQldt.getToken()
|
||||||
|
.then((res) => {
|
||||||
|
setQldtToken(res.token || '');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setQldtErr(err.message || 'Lỗi khi tải token QLĐT');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingQldt(false);
|
||||||
|
});
|
||||||
|
}, [isQldtAdmin]);
|
||||||
|
|
||||||
|
const saveQldtToken = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setQldtMsg('');
|
||||||
|
setQldtErr('');
|
||||||
|
try {
|
||||||
|
await apiQldt.saveToken(qldtToken);
|
||||||
|
setQldtMsg('Đã lưu cấu hình QLĐT token');
|
||||||
|
} catch (err: any) {
|
||||||
|
setQldtErr(err.message || 'Lỗi khi lưu token QLĐT');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitPw = async (e: React.FormEvent) => {
|
const submitPw = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErr('');
|
setPwErr('');
|
||||||
setMsg('');
|
setPwMsg('');
|
||||||
try {
|
try {
|
||||||
await changePassword(oldPw, newPw);
|
await changePassword(oldPw, newPw);
|
||||||
setMsg('Đã đổi mật khẩu');
|
setPwMsg('Đã đổi mật khẩu');
|
||||||
setOldPw('');
|
setOldPw('');
|
||||||
setNewPw('');
|
setNewPw('');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message || 'Lỗi');
|
setPwErr(e?.message || 'Lỗi');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,7 +140,7 @@ export function MyAccountTab() {
|
|||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1 className="page-title">Tài khoản của tôi</h1>
|
<h1 className="page-title">Tài khoản của tôi</h1>
|
||||||
<p className="page-desc">Thông tin đăng nhập và bảo mật cá nhân.</p>
|
<p className="page-desc">Thông tin đăng nhập, GitHub và bảo mật cá nhân.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
@@ -161,6 +149,96 @@ export function MyAccountTab() {
|
|||||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p>
|
<p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card account-github-card" style={{ padding: '1.25rem' }}>
|
||||||
|
<h2 className="section-title">GitHub</h2>
|
||||||
|
<p className="page-desc" style={{ marginTop: 0 }}>
|
||||||
|
Kết nối tài khoản GitHub của bạn để đẩy bài nộp phòng thi lên repo riêng. Mỗi giáo viên dùng GitHub của mình — không dùng chung token server.
|
||||||
|
</p>
|
||||||
|
<div className="exam-git-oauth-row">
|
||||||
|
{gh.connected ? (
|
||||||
|
<>
|
||||||
|
<span className="exam-git-connected">
|
||||||
|
Đã kết nối: <strong>@{gh.githubLogin}</strong>
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={gh.disconnect}>
|
||||||
|
Ngắt kết nối
|
||||||
|
</button>
|
||||||
|
{!gh.canDeleteRepos && (
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={gh.connecting} onClick={gh.connect}>
|
||||||
|
{gh.connecting ? 'Đang mở GitHub...' : 'Cấp quyền xóa repo'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={gh.connecting}
|
||||||
|
onClick={gh.connect}
|
||||||
|
>
|
||||||
|
{gh.connecting ? 'Đang mở GitHub...' : 'Kết nối GitHub'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{gh.connected && !gh.canDeleteRepos && (
|
||||||
|
<div className="login-error" style={{ marginTop: '0.75rem' }}>
|
||||||
|
Token hiện tại chưa có quyền <strong>delete_repo</strong> — không xóa được repo. Bấm <strong>Cấp quyền xóa repo</strong> (hoặc ngắt kết nối rồi kết nối lại).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gh.error && <div className="login-error">{gh.error}</div>}
|
||||||
|
{gh.message && <div className="login-success">{gh.message}</div>}
|
||||||
|
{!gh.connected && (
|
||||||
|
<p className="exam-git-hint">
|
||||||
|
Sau khi kết nối, vào <strong>Phòng thi</strong> → tab <strong>Bài nộp</strong> → đẩy Git (gộp hoặc từng sinh viên).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{gh.connected && (
|
||||||
|
<>
|
||||||
|
<h3 className="section-subtitle" style={{ marginTop: '1rem' }}>Repo đã tạo</h3>
|
||||||
|
<GitHubReposPanel connected={gh.connected} canDeleteRepos={gh.canDeleteRepos} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isQldtAdmin && (
|
||||||
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
<h2 className="section-title">Cấu hình QLĐT Token</h2>
|
||||||
|
<p className="page-desc" style={{ marginTop: 0 }}>
|
||||||
|
Cấu hình token đồng bộ dữ liệu lớp học, môn học và điểm danh từ Cổng đào tạo (QLĐT).
|
||||||
|
</p>
|
||||||
|
{loadingQldt ? (
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>Đang tải cấu hình...</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={saveQldtToken} className="login-form">
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Token QLĐT</span>
|
||||||
|
<textarea
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '100px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border-color, #ccc)',
|
||||||
|
backgroundColor: 'var(--bg-input, #fff)',
|
||||||
|
color: 'var(--text, #333)',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
value={qldtToken}
|
||||||
|
onChange={(e) => setQldtToken(e.target.value)}
|
||||||
|
placeholder="Nhập JWT Token từ QLĐT..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{qldtErr && <div className="login-error">{qldtErr}</div>}
|
||||||
|
{qldtMsg && <div className="login-success">{qldtMsg}</div>}
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ marginTop: '0.5rem' }}>Lưu cấu hình</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
<h2 className="section-title">Đổi mật khẩu</h2>
|
<h2 className="section-title">Đổi mật khẩu</h2>
|
||||||
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
|
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
|
||||||
@@ -172,8 +250,8 @@ export function MyAccountTab() {
|
|||||||
<span>Mật khẩu mới</span>
|
<span>Mật khẩu mới</span>
|
||||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
|
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
|
||||||
</label>
|
</label>
|
||||||
{err && <div className="login-error">{err}</div>}
|
{pwErr && <div className="login-error">{pwErr}</div>}
|
||||||
{msg && <div className="login-success">{msg}</div>}
|
{pwMsg && <div className="login-success">{pwMsg}</div>}
|
||||||
<button type="submit" className="btn btn-primary">Lưu</button>
|
<button type="submit" className="btn btn-primary">Lưu</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,48 @@ interface AppPoolModalProps {
|
|||||||
allowedApps: string;
|
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) => {
|
const formatWhen = (iso: string) => {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -80,44 +122,63 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||||
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
|
<div className="modal-container app-pool-modal app-picker-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header app-picker-header">
|
||||||
<div>
|
<div className="app-picker-header-text">
|
||||||
<h2 className="modal-title" style={{ margin: 0 }}>Kho ứng dụng</h2>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="app-pool-toolbar">
|
<div className="app-pool-toolbar">
|
||||||
<input
|
<div className="app-picker-search-wrap">
|
||||||
type="search"
|
<span className="app-picker-search-icon" aria-hidden>
|
||||||
className="app-pool-search"
|
<IconSearch />
|
||||||
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
|
</span>
|
||||||
value={search}
|
<input
|
||||||
onChange={e => setSearch(e.target.value)}
|
type="search"
|
||||||
autoFocus
|
className="app-pool-search"
|
||||||
/>
|
placeholder="Tìm theo tên app, keyword, tiêu đề cửa sổ..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary app-picker-refresh"
|
||||||
onClick={() => loadPool(debouncedQ)}
|
onClick={() => loadPool(debouncedQ)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
|
<IconRefresh />
|
||||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-pool-body">
|
<div className="app-pool-body">
|
||||||
{error ? (
|
{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 ? (
|
) : 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 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="app-pool-status">
|
<div className="app-picker-state">
|
||||||
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có app nào trong kho.'}
|
<IconInbox />
|
||||||
|
<p>
|
||||||
|
{debouncedQ ? `Không tìm thấy “${debouncedQ}”` : 'Chưa có app nào trong kho.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table app-pool-table">
|
<table className="data-table app-pool-table">
|
||||||
@@ -128,7 +189,7 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
|||||||
<th>Tiêu đề</th>
|
<th>Tiêu đề</th>
|
||||||
<th>Lần chặn</th>
|
<th>Lần chặn</th>
|
||||||
<th>Gần nhất</th>
|
<th>Gần nhất</th>
|
||||||
<th></th>
|
<th style={{ textAlign: 'right' }}>Thao tác</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -136,17 +197,33 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
|||||||
const added = allowedSet.has(app.keyword.toLowerCase());
|
const added = allowedSet.has(app.keyword.toLowerCase());
|
||||||
return (
|
return (
|
||||||
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}>
|
<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>
|
<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 ? (
|
{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)}>
|
<button
|
||||||
+ Thêm
|
type="button"
|
||||||
|
className="btn btn-primary app-pool-add-btn"
|
||||||
|
onClick={() => handleSelect(app.keyword)}
|
||||||
|
>
|
||||||
|
<IconPlus />
|
||||||
|
Thêm
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -159,7 +236,9 @@ export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="app-pool-footer">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
212
management/src/components/AppTemplatePickerModal.tsx
Normal file
212
management/src/components/AppTemplatePickerModal.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||||
|
import { countKeywords } from '../utils/appKeywords';
|
||||||
|
|
||||||
|
interface AppTemplatePickerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (keywords: string) => void;
|
||||||
|
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,
|
||||||
|
onSelect,
|
||||||
|
allowedApps,
|
||||||
|
}) => {
|
||||||
|
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await apiAppTemplates.list();
|
||||||
|
setItems(res.data || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setItems([]);
|
||||||
|
setError(e?.message || 'Không tải được khung ứng dụng');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? items.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.description?.toLowerCase().includes(q) ||
|
||||||
|
t.keywords.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||||
|
<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 whitelist. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
placeholder="Tìm theo tên, mô tả, keyword..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</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="app-picker-state app-picker-state--error">{error}</div>
|
||||||
|
) : loading && items.length === 0 ? (
|
||||||
|
<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-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) => {
|
||||||
|
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 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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm template-picker-apply"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(tpl.keywords);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconCheck size={13} />
|
||||||
|
Áp dụng
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-pool-footer">
|
||||||
|
Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
|
||||||
|
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
210
management/src/components/AppTemplatesSection.tsx
Normal file
210
management/src/components/AppTemplatesSection.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||||
|
import { AppPoolModal } from './AppPoolModal';
|
||||||
|
import { countKeywords, mergeKeywordCSV } from '../utils/appKeywords';
|
||||||
|
|
||||||
|
export function AppTemplatesSection() {
|
||||||
|
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState<AppTemplateItem | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [keywords, setKeywords] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [poolOpen, setPoolOpen] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAppTemplates.list();
|
||||||
|
setItems(res.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setCreating(true);
|
||||||
|
setEditing(null);
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setKeywords('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (tpl: AppTemplateItem) => {
|
||||||
|
setEditing(tpl);
|
||||||
|
setCreating(false);
|
||||||
|
setName(tpl.name);
|
||||||
|
setDescription(tpl.description || '');
|
||||||
|
setKeywords(tpl.keywords);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Nhập tên khung.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!keywords.trim()) {
|
||||||
|
setError('Thêm ít nhất một keyword.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await apiAppTemplates.update(editing.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
keywords,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiAppTemplates.create({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
keywords,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Lỗi lưu khung');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (tpl: AppTemplateItem) => {
|
||||||
|
if (!confirm(`Xóa khung "${tpl.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await apiAppTemplates.delete(tpl.id);
|
||||||
|
if (editing?.id === tpl.id) closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Không xóa được');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPoolKeyword = (kw: string) => {
|
||||||
|
setKeywords((prev) => mergeKeywordCSV(prev, kw));
|
||||||
|
};
|
||||||
|
|
||||||
|
const showForm = creating || editing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-section">
|
||||||
|
<div className="system-block">
|
||||||
|
<div className="system-block-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="system-block-title">Khung ứng dụng</h2>
|
||||||
|
<p className="system-block-desc">
|
||||||
|
Tạo bộ keyword whitelist dùng chung — ghép từ kho app hoặc tự nhập. Lớp học và phòng thi có thể áp dụng nhanh thay vì chỉ chọn từng app trong kho.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||||
|
+ Tạo khung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="system-form-card">
|
||||||
|
<h3 className="system-form-title">{editing ? 'Sửa khung' : 'Khung mới'}</h3>
|
||||||
|
<div className="system-form-grid">
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Tên khung</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="VD: Java IDE, Thi cuối kỳ..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="login-field system-form-full">
|
||||||
|
<span>Mô tả (tuỳ chọn)</span>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Ghi chú ngắn cho giáo viên"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="login-field system-form-full">
|
||||||
|
<span>Keywords (phân tách bằng dấu phẩy)</span>
|
||||||
|
<textarea
|
||||||
|
className="app-textarea"
|
||||||
|
rows={4}
|
||||||
|
value={keywords}
|
||||||
|
onChange={(e) => setKeywords(e.target.value)}
|
||||||
|
placeholder="chrome, vscode, cursor, goland, client"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="system-form-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||||
|
Thêm từ kho app
|
||||||
|
</button>
|
||||||
|
<div className="system-form-actions-right">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={closeForm}>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
||||||
|
{busy ? 'Đang lưu...' : 'Lưu khung'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="system-empty">Đang tải...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="system-empty">
|
||||||
|
Chưa có khung nào. Bấm <strong>Tạo khung</strong> để ghép keyword từ kho hoặc tự ghi.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="template-grid">
|
||||||
|
{items.map((tpl) => (
|
||||||
|
<article key={tpl.id} className="template-card">
|
||||||
|
<div className="template-card-head">
|
||||||
|
<h3>{tpl.name}</h3>
|
||||||
|
<span className="template-card-badge">{countKeywords(tpl.keywords)} kw</span>
|
||||||
|
</div>
|
||||||
|
{tpl.description && <p className="template-card-desc">{tpl.description}</p>}
|
||||||
|
<code className="template-card-kw">{tpl.keywords}</code>
|
||||||
|
<div className="template-card-actions">
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openEdit(tpl)}>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(tpl)}>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppPoolModal
|
||||||
|
open={poolOpen}
|
||||||
|
onClose={() => setPoolOpen(false)}
|
||||||
|
onSelect={addPoolKeyword}
|
||||||
|
allowedApps={keywords}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
653
management/src/components/ApplicationsTab.tsx
Normal file
653
management/src/components/ApplicationsTab.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { api, type AppDownloadItem, type AppGuideItem } from '../api';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const IconDownload = () => (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlatformIcon = ({ platform }: { platform: string }) => {
|
||||||
|
const p = platform.toLowerCase();
|
||||||
|
if (p.includes('win')) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#0078d7' }}>
|
||||||
|
<path d="M0 3.449L9.75 2.1v9.45H0V3.449zM0 12.45h9.75v9.45L0 20.551v-8.1zM10.8 1.95L24 0v11.55H10.8V1.95zM10.8 12.45H24v11.55l-13.2-1.95v-9.6z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.includes('mac') || p.includes('apple') || p.includes('ios')) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#555555' }}>
|
||||||
|
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M15.97 4.17c.66-.81 1.11-1.93.99-3.05-1 .04-2.22.67-2.94 1.51-.62.73-1.16 1.87-1.01 2.97 1.12.09 2.27-.58 2.96-1.43z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.includes('linux')) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" style={{ color: '#FCC624' }}>
|
||||||
|
<path d="M12 2c-3.3 0-6 2.7-6 6 0 2.2.8 4.2 2.2 5.6C6.5 15.6 5 18.5 5 22h14c0-3.5-1.5-6.4-3.2-8.4 1.4-1.4 2.2-3.4 2.2-5.6 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4 0 .9-.3 1.7-.8 2.3-.3.4-.7.7-1.2.9-.6.2-1.3.3-2 .3s-1.4-.1-2-.3c-.5-.2-.9-.5-1.2-.9-.5-.6-.8-1.4-.8-2.3 0-2.2 1.8-4 4-4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--accent)' }}>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApplicationsTab: React.FC = () => {
|
||||||
|
const { staff } = useAuth();
|
||||||
|
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||||
|
|
||||||
|
const [activeSubTab, setActiveSubTab] = useState<'apps' | 'guides'>('apps');
|
||||||
|
|
||||||
|
// Apps state
|
||||||
|
const [apps, setApps] = useState<AppDownloadItem[]>([]);
|
||||||
|
const [appsLoading, setAppsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Guides state
|
||||||
|
const [guides, setGuides] = useState<AppGuideItem[]>([]);
|
||||||
|
const [guidesLoading, setGuidesLoading] = useState(true);
|
||||||
|
|
||||||
|
// App Modal states
|
||||||
|
const [showAppModal, setShowAppModal] = useState(false);
|
||||||
|
const [editingApp, setEditingApp] = useState<AppDownloadItem | null>(null);
|
||||||
|
const [appName, setAppName] = useState('');
|
||||||
|
const [appDescription, setAppDescription] = useState('');
|
||||||
|
const [appDownloadUrl, setAppDownloadUrl] = useState('');
|
||||||
|
const [appPlatform, setAppPlatform] = useState('Windows');
|
||||||
|
const [appSaving, setAppSaving] = useState(false);
|
||||||
|
|
||||||
|
// Guide Modal states
|
||||||
|
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||||
|
const [editingGuide, setEditingGuide] = useState<AppGuideItem | null>(null);
|
||||||
|
const [guideTitle, setGuideTitle] = useState('');
|
||||||
|
const [guideUrl, setGuideUrl] = useState('');
|
||||||
|
const [guideSaving, setGuideSaving] = useState(false);
|
||||||
|
|
||||||
|
const fetchApps = async () => {
|
||||||
|
try {
|
||||||
|
setAppsLoading(true);
|
||||||
|
const res = await api.listAppDownloads();
|
||||||
|
setApps(res.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Không thể tải danh sách ứng dụng:', err);
|
||||||
|
} finally {
|
||||||
|
setAppsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGuides = async () => {
|
||||||
|
try {
|
||||||
|
setGuidesLoading(true);
|
||||||
|
const res = await api.listAppGuides();
|
||||||
|
setGuides(res.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Không thể tải danh sách hướng dẫn sinh viên:', err);
|
||||||
|
} finally {
|
||||||
|
setGuidesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApps();
|
||||||
|
fetchGuides();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// App Modal Handlers
|
||||||
|
const handleOpenAddApp = () => {
|
||||||
|
setEditingApp(null);
|
||||||
|
setAppName('');
|
||||||
|
setAppDescription('');
|
||||||
|
setAppDownloadUrl('');
|
||||||
|
setAppPlatform('Windows');
|
||||||
|
setShowAppModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditApp = (app: AppDownloadItem) => {
|
||||||
|
setEditingApp(app);
|
||||||
|
setAppName(app.name);
|
||||||
|
setAppDescription(app.description);
|
||||||
|
setAppDownloadUrl(app.downloadUrl);
|
||||||
|
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
|
||||||
|
setShowAppModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveApp = async () => {
|
||||||
|
if (!appName.trim() || !appDownloadUrl.trim()) {
|
||||||
|
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let finalPlatform = appPlatform;
|
||||||
|
if (finalPlatform.toLowerCase() === 'macos') {
|
||||||
|
finalPlatform = 'MacOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAppSaving(true);
|
||||||
|
if (editingApp) {
|
||||||
|
await api.updateAppDownload(editingApp.id, appName.trim(), appDescription.trim(), appDownloadUrl.trim(), finalPlatform);
|
||||||
|
} else {
|
||||||
|
await api.createAppDownload(appName.trim(), appDescription.trim(), appDownloadUrl.trim(), finalPlatform);
|
||||||
|
}
|
||||||
|
setShowAppModal(false);
|
||||||
|
await fetchApps();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Lưu ứng dụng thất bại');
|
||||||
|
} finally {
|
||||||
|
setAppSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteApp = async (id: number, name: string) => {
|
||||||
|
if (window.confirm(`Bạn có chắc chắn muốn xóa liên kết tải cho ứng dụng "${name}" không?`)) {
|
||||||
|
try {
|
||||||
|
await api.deleteAppDownload(id);
|
||||||
|
await fetchApps();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Xóa ứng dụng thất bại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guide Modal Handlers
|
||||||
|
const handleOpenAddGuide = () => {
|
||||||
|
setEditingGuide(null);
|
||||||
|
setGuideTitle('');
|
||||||
|
setGuideUrl('');
|
||||||
|
setShowGuideModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditGuide = (guide: AppGuideItem) => {
|
||||||
|
setEditingGuide(guide);
|
||||||
|
setGuideTitle(guide.title);
|
||||||
|
setGuideUrl(guide.url);
|
||||||
|
setShowGuideModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveGuide = async () => {
|
||||||
|
if (!guideTitle.trim() || !guideUrl.trim()) {
|
||||||
|
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn hướng dẫn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setGuideSaving(true);
|
||||||
|
if (editingGuide) {
|
||||||
|
await api.updateAppGuide(editingGuide.id, guideTitle.trim(), guideUrl.trim());
|
||||||
|
} else {
|
||||||
|
await api.createAppGuide(guideTitle.trim(), guideUrl.trim());
|
||||||
|
}
|
||||||
|
setShowGuideModal(false);
|
||||||
|
await fetchGuides();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Lưu hướng dẫn thất bại');
|
||||||
|
} finally {
|
||||||
|
setGuideSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteGuide = async (id: number, title: string) => {
|
||||||
|
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu hướng dẫn sinh viên "${title}"?`)) {
|
||||||
|
try {
|
||||||
|
await api.deleteAppGuide(id);
|
||||||
|
await fetchGuides();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Xóa hướng dẫn thất bại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-page">
|
||||||
|
<header className="page-header page-header--row">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Ứng Dụng & Hướng Dẫn</h1>
|
||||||
|
<p className="page-desc">Quản lý các công cụ, link tải ứng dụng và các tài liệu hướng dẫn cho sinh viên.</p>
|
||||||
|
</div>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div>
|
||||||
|
{activeSubTab === 'apps' ? (
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenAddApp}>
|
||||||
|
+ Thêm ứng dụng
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenAddGuide}>
|
||||||
|
+ Thêm tài liệu hướng dẫn
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 2rem' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
borderBottom: '1px solid var(--border-color)',
|
||||||
|
paddingBottom: '12px',
|
||||||
|
marginBottom: '1.5rem'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${activeSubTab === 'apps' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
onClick={() => setActiveSubTab('apps')}
|
||||||
|
style={{
|
||||||
|
padding: '0.6rem 1.5rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ứng dụng tải về
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn ${activeSubTab === 'guides' ? 'btn-primary' : 'btn-ghost'}`}
|
||||||
|
onClick={() => setActiveSubTab('guides')}
|
||||||
|
style={{
|
||||||
|
padding: '0.6rem 1.5rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tài liệu & Video Hướng dẫn
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tab-page-body tab-page-scroll" style={{ padding: '0 2rem 2rem 2rem' }}>
|
||||||
|
{activeSubTab === 'apps' && (
|
||||||
|
<div className="content-card" style={{ padding: '1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)' }}>
|
||||||
|
{appsLoading && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '4rem 1.5rem' }}>
|
||||||
|
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách ứng dụng...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!appsLoading && apps.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1.5rem', 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" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M21 12H3M12 3v18" />
|
||||||
|
</svg>
|
||||||
|
<p style={{ fontSize: '0.95rem' }}>Chưa có ứng dụng nào được cấu hình tải.</p>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button className="btn btn-secondary" onClick={handleOpenAddApp} style={{ marginTop: '1rem' }}>
|
||||||
|
Thêm ứng dụng đầu tiên
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!appsLoading && apps.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Nền tảng</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '25%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Ứng dụng</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '35%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Mô tả</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Đường dẫn tải</th>
|
||||||
|
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{apps.map((app) => (
|
||||||
|
<tr key={app.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<PlatformIcon platform={app.platform} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)' }}>
|
||||||
|
{app.platform === 'macOS' ? 'MacOS' : app.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}>
|
||||||
|
{app.name}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', color: 'var(--text-secondary)', fontSize: '0.9rem', lineHeight: '1.5' }}>
|
||||||
|
{app.description || '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<a
|
||||||
|
href={app.downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||||
|
>
|
||||||
|
<IconDownload />
|
||||||
|
Tải ứng dụng
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'inline-flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenEditApp(app)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleDeleteApp(app.id, app.name)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSubTab === 'guides' && (
|
||||||
|
<div className="content-card" style={{ padding: '1.5rem', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-sm)' }}>
|
||||||
|
{guidesLoading && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '4rem 1.5rem' }}>
|
||||||
|
<div className="sync-spinner" style={{ width: '36px', height: '36px', borderWidth: '3px' }}></div>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Đang tải danh sách tài liệu hướng dẫn...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!guidesLoading && guides.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '4rem 1.5rem', 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" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<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>
|
||||||
|
<p style={{ fontSize: '0.95rem' }}>Chưa có tài liệu hướng dẫn sinh viên nào.</p>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button className="btn btn-secondary" onClick={handleOpenAddGuide} style={{ marginTop: '1rem' }}>
|
||||||
|
Thêm hướng dẫn sinh viên đầu tiên
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!guidesLoading && guides.length > 0 && (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table" style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Loại tài liệu</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '50%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Tiêu đề hướng dẫn sinh viên</th>
|
||||||
|
<th style={{ padding: '1.15rem 1.5rem', width: '25%', fontWeight: 700, color: 'var(--text-secondary)', textAlign: 'left' }}>Đường dẫn liên kết</th>
|
||||||
|
{isSuperAdmin && <th style={{ padding: '1.15rem 1.5rem', width: '10%', textAlign: 'right', fontWeight: 700, color: 'var(--text-secondary)' }}>Thao tác</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{guides.map((guide) => {
|
||||||
|
const isVideo = guide.url.toLowerCase().includes('youtube.com') || guide.url.toLowerCase().includes('youtu.be') || guide.url.toLowerCase().includes('drive.google.com/file');
|
||||||
|
return (
|
||||||
|
<tr key={guide.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<span className="platform-badge" style={{ backgroundColor: isVideo ? '#ffebee' : '#e3f2fd', color: isVideo ? '#d32f2f' : '#1976d2', padding: '0.3rem 0.6rem', fontSize: '0.75rem', borderRadius: '4px', fontWeight: 700 }}>
|
||||||
|
{isVideo ? 'VIDEO' : 'TÀI LIỆU'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', fontWeight: 700, color: 'var(--text-primary)', fontSize: '0.95rem' }}>
|
||||||
|
{guide.title}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem' }}>
|
||||||
|
<a
|
||||||
|
href={guide.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||||
|
>
|
||||||
|
Xem liên kết →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<td style={{ padding: '1.15rem 1.5rem', textAlign: 'right' }}>
|
||||||
|
<div style={{ display: 'inline-flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenEditGuide(guide)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleDeleteGuide(guide.id, guide.title)}
|
||||||
|
style={{ padding: '0.35rem 0.75rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App Modal */}
|
||||||
|
{showAppModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAppModal(false)}>
|
||||||
|
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">{editingApp ? 'Cập nhật ứng dụng' : 'Thêm ứng dụng tải'}</h2>
|
||||||
|
<button className="modal-close-btn" onClick={() => setShowAppModal(false)} aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tên ứng dụng</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={appName}
|
||||||
|
onChange={e => setAppName(e.target.value)}
|
||||||
|
placeholder="VD: Simple Care Client v1.2"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Nền tảng</span>
|
||||||
|
<select
|
||||||
|
value={appPlatform}
|
||||||
|
onChange={e => setAppPlatform(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
>
|
||||||
|
<option value="Windows">Windows</option>
|
||||||
|
<option value="MacOS">MacOS</option>
|
||||||
|
<option value="Linux">Linux</option>
|
||||||
|
<option value="Khác">Khác</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Mô tả ngắn</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={appDescription}
|
||||||
|
onChange={e => setAppDescription(e.target.value)}
|
||||||
|
placeholder="VD: Dành cho máy Windows 64-bit"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn tải</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={appDownloadUrl}
|
||||||
|
onChange={e => setAppDownloadUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowAppModal(false)}>Hủy</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSaveApp}
|
||||||
|
disabled={appSaving}
|
||||||
|
>
|
||||||
|
{appSaving ? 'Đang lưu...' : 'Lưu lại'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Guide Modal */}
|
||||||
|
{showGuideModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowGuideModal(false)}>
|
||||||
|
<div className="modal-container" style={{ maxWidth: '480px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">{editingGuide ? 'Cập nhật hướng dẫn sinh viên' : 'Thêm tài liệu hướng dẫn sinh viên'}</h2>
|
||||||
|
<button className="modal-close-btn" onClick={() => setShowGuideModal(false)} aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đề hướng dẫn</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={guideTitle}
|
||||||
|
onChange={e => setGuideTitle(e.target.value)}
|
||||||
|
placeholder="VD: Hướng dẫn cài đặt và chạy ứng dụng cho sinh viên"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn hướng dẫn (Google Docs / Video Youtube)</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={guideUrl}
|
||||||
|
onChange={e => setGuideUrl(e.target.value)}
|
||||||
|
placeholder="https://docs.google.com/document/d/... hoặc https://youtube.com/..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.6rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowGuideModal(false)}>Hủy</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSaveGuide}
|
||||||
|
disabled={guideSaving}
|
||||||
|
>
|
||||||
|
{guideSaving ? 'Đang lưu...' : 'Lưu lại'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,13 +5,110 @@ import {
|
|||||||
apiFetchAttendanceShifts,
|
apiFetchAttendanceShifts,
|
||||||
apiPushAttendanceQLDT,
|
apiPushAttendanceQLDT,
|
||||||
apiUpdateAttendanceStatus,
|
apiUpdateAttendanceStatus,
|
||||||
|
apiUpdateAttendanceBulkStatus,
|
||||||
attendanceStatusClass,
|
attendanceStatusClass,
|
||||||
type AttendanceRow,
|
type AttendanceRow,
|
||||||
type AttendanceShiftInfo,
|
type AttendanceShiftInfo,
|
||||||
|
apiFetchLeaveRequests,
|
||||||
|
apiUpdateLeaveStatus,
|
||||||
|
type LeaveRequestItem,
|
||||||
} from '../api';
|
} 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 {
|
interface AttendancePanelProps {
|
||||||
classId: number;
|
classId: number;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatQldtTime(iso?: string): string {
|
function formatQldtTime(iso?: string): string {
|
||||||
@@ -24,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 today = new Date().toISOString().slice(0, 10);
|
||||||
const [date, setDate] = useState(today);
|
const [date, setDate] = useState(today);
|
||||||
const [period, setPeriod] = useState(1);
|
const [period, setPeriod] = useState(1);
|
||||||
@@ -34,6 +133,16 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pushing, setPushing] = useState(false);
|
const [pushing, setPushing] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedStudentRkIds, setSelectedStudentRkIds] = useState<number[]>([]);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [leaveRequests, setLeaveRequests] = useState<LeaveRequestItem[]>([]);
|
||||||
|
const [loadingLeave, setLoadingLeave] = useState(false);
|
||||||
|
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||||
|
|
||||||
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const scrollTop = e.currentTarget.scrollTop;
|
||||||
|
setIsCollapsed(scrollTop > 20);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadShifts = useCallback(async () => {
|
const loadShifts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -42,53 +151,109 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
|||||||
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
|
if (res.data?.length && !res.data.find((s: any) => s.period === period)) {
|
||||||
setPeriod(res.data[0].period || 1);
|
setPeriod(res.data[0].period || 1);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { setShifts([]); }
|
||||||
setShifts([]);
|
|
||||||
}
|
|
||||||
}, [classId, date, period]);
|
}, [classId, date, period]);
|
||||||
|
|
||||||
const loadAttendance = useCallback(async () => {
|
const loadAttendance = useCallback(async (isBackground: boolean | any = false) => {
|
||||||
|
const isBg = isBackground === true;
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
if (!isBg) setLoading(true);
|
||||||
const res = await apiFetchAttendance(classId, date, period);
|
const res = await apiFetchAttendance(classId, date, period);
|
||||||
setRows(res.data || []);
|
setRows(res.data || []);
|
||||||
setShiftInfo(res.shift || null);
|
setShiftInfo(res.shift || null);
|
||||||
|
if (!isBg) setSelectedStudentRkIds([]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message || 'Không tải được điểm danh');
|
alert(e.message || 'Không tải được điểm danh');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!isBg) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [classId, date, period]);
|
}, [classId, date, period]);
|
||||||
|
|
||||||
|
const currentShift = useMemo(() => shifts.find(s => s.period === period), [shifts, period]);
|
||||||
|
const courseId = currentShift?.courseId;
|
||||||
|
|
||||||
|
const loadLeaveRequests = useCallback(async () => {
|
||||||
|
if (!courseId) { setLeaveRequests([]); return; }
|
||||||
|
try {
|
||||||
|
setLoadingLeave(true);
|
||||||
|
const res = await apiFetchLeaveRequests(classId, courseId, date);
|
||||||
|
setLeaveRequests(res || []);
|
||||||
|
} catch { setLeaveRequests([]); } finally { setLoadingLeave(false); }
|
||||||
|
}, [classId, courseId, date]);
|
||||||
|
|
||||||
useEffect(() => { loadShifts(); }, [loadShifts]);
|
useEffect(() => { loadShifts(); }, [loadShifts]);
|
||||||
useEffect(() => { loadAttendance(); }, [loadAttendance]);
|
useEffect(() => { loadAttendance(); }, [loadAttendance]);
|
||||||
|
useEffect(() => { loadLeaveRequests(); }, [loadLeaveRequests]);
|
||||||
|
|
||||||
|
const handleUpdateLeaveStatus = async (leaveId: number, status: string, studentRkId: number) => {
|
||||||
|
try {
|
||||||
|
await apiUpdateLeaveStatus(classId, leaveId, { status, studentRkId, date, period });
|
||||||
|
await loadAttendance();
|
||||||
|
await loadLeaveRequests();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message || 'Cập nhật đơn xin nghỉ thất bại');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
if (!q) return rows;
|
if (!q) return rows;
|
||||||
return rows.filter(row => {
|
return rows.filter(row => {
|
||||||
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel]
|
const haystack = [row.fullName, row.studentCode, row.email, row.statusLabel].filter(Boolean).join(' ').toLowerCase();
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
return haystack.includes(q);
|
return haystack.includes(q);
|
||||||
});
|
});
|
||||||
}, [rows, searchQuery]);
|
}, [rows, searchQuery]);
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
|
const counts: Record<number, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||||
for (const row of rows) {
|
for (const row of rows) { if (counts[row.status] !== undefined) counts[row.status]++; }
|
||||||
if (counts[row.status] !== undefined) counts[row.status]++;
|
|
||||||
}
|
|
||||||
return counts;
|
return counts;
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
|
const toggleStudentSelection = (studentRkId: number) => {
|
||||||
|
setSelectedStudentRkIds(prev =>
|
||||||
|
prev.includes(studentRkId) ? prev.filter(id => id !== studentRkId) : [...prev, studentRkId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (studentRkId: number, status: number) => {
|
const handleStatusChange = async (studentRkId: number, status: number) => {
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
|
await apiUpdateAttendanceStatus(classId, { date, period, studentRkId, status });
|
||||||
await loadAttendance();
|
await loadAttendance(true);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message || 'Cập nhật trạng thái thất bại');
|
alert(e.message || 'Cập nhật trạng thái thất bại');
|
||||||
|
await loadAttendance();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkStatusChange = async (status: number) => {
|
||||||
|
if (selectedStudentRkIds.length === 0) return;
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const idsToUpdate = [...selectedStudentRkIds];
|
||||||
|
setSelectedStudentRkIds([]);
|
||||||
|
try {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,121 +270,358 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
|||||||
await loadAttendance();
|
await loadAttendance();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message || 'Đẩy QLĐT thất bại');
|
alert(e.message || 'Đẩy QLĐT thất bại');
|
||||||
} finally {
|
} finally { setPushing(false); }
|
||||||
setPushing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentShift = shifts.find(s => s.period === period);
|
|
||||||
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
|
const qldtSynced = Boolean(shiftInfo?.pushedToQldtAt);
|
||||||
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
const qldtDirty = Boolean(shiftInfo?.qldtDirty);
|
||||||
|
|
||||||
|
const hasPendingLeave = leaveRequests.some(r => r.status === 'Đang chờ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attendance-panel">
|
<div className="ap-panel">
|
||||||
<div className="attendance-toolbar">
|
<style>{`
|
||||||
<label className="attendance-field">
|
.ap-panel { display: flex; flex-direction: column; height: 100%; gap: 0.65rem; }
|
||||||
<span>Ngày</span>
|
|
||||||
<input
|
/* Header */
|
||||||
type="date"
|
.ap-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
className="search-input"
|
.ap-header-title { display: flex; align-items: center; gap: 0.5rem; margin: 0; font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
|
||||||
style={{ padding: '0.5rem 0.75rem' }}
|
.ap-header-title svg { color: var(--accent); flex-shrink: 0; }
|
||||||
value={date}
|
.ap-header-actions { display: flex; gap: 0.3rem; align-items: center; flex-wrap: wrap; }
|
||||||
onChange={e => setDate(e.target.value)}
|
|
||||||
/>
|
/* Buttons */
|
||||||
</label>
|
.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; }
|
||||||
<label className="attendance-field">
|
.ap-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-hover); }
|
||||||
<span>Ca học</span>
|
.ap-btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
<select className="select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
.ap-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
{(shifts.length ? shifts : [{ period: 1 }, { period: 2 }, { period: 3 }, { period: 4 }]).map((s: any) => (
|
.ap-btn--primary:hover:not(:disabled) { filter: brightness(1.08); background: var(--accent); }
|
||||||
<option key={s.period} value={s.period}>
|
.ap-btn--close { padding: 0.22rem 0.45rem; }
|
||||||
Ca {s.period}{s.startTime ? ` (${s.startTime}–${s.endTime})` : ''}
|
.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; }
|
||||||
</option>
|
.ap-btn--leave-badge.muted { background: var(--text-secondary); }
|
||||||
))}
|
|
||||||
</select>
|
/* Collapsible toolbar area */
|
||||||
</label>
|
.ap-collapsible { display: flex; flex-direction: column; gap: 0.5rem; overflow: hidden; transition: max-height 0.3s ease; }
|
||||||
<label className="attendance-field attendance-field--grow">
|
.ap-collapsible.collapsed { max-height: 0 !important; }
|
||||||
<span>Tìm sinh viên</span>
|
|
||||||
<input
|
/* Toolbar */
|
||||||
type="search"
|
.ap-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; }
|
||||||
className="app-pool-search attendance-search"
|
.ap-field { display: flex; flex-direction: column; gap: 0.18rem; font-size: 0.71rem; font-weight: 600; color: var(--text-muted); }
|
||||||
placeholder="Mã SV, tên, email..."
|
.ap-field--grow { flex: 1; }
|
||||||
value={searchQuery}
|
.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); }
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
.ap-input:focus { outline: none; border-color: var(--accent); }
|
||||||
/>
|
|
||||||
</label>
|
/* QLĐT sync banner */
|
||||||
<button type="button" className="btn btn-secondary" onClick={loadAttendance} disabled={loading}>
|
.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; }
|
||||||
Tải lại
|
.ap-qldt-banner--synced { background: rgba(46,125,50,0.06); border-color: rgba(46,125,50,0.3); color: #2e7d32; }
|
||||||
</button>
|
.ap-qldt-banner--stale { background: rgba(180,83,9,0.06); border-color: rgba(180,83,9,0.35); color: #92400e; }
|
||||||
<button type="button" className="btn btn-primary" onClick={handlePushQLDT} disabled={pushing || rows.length === 0}>
|
|
||||||
{pushing ? 'Đang đẩy...' : 'Đẩy QLĐT'}
|
/* Shift info strip */
|
||||||
</button>
|
.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="ap-btn"
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
>
|
||||||
|
{isCollapsed ? <><IconChevronDown size={14} /> Chi tiết</> : <><IconChevronUp size={14} /> Thu gọn</>}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ap-btn"
|
||||||
|
onClick={() => loadAttendance(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<IconRefresh size={14} />
|
||||||
|
Tải lại
|
||||||
|
</button>
|
||||||
|
{courseId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ap-btn"
|
||||||
|
onClick={() => setShowLeaveModal(true)}
|
||||||
|
>
|
||||||
|
<IconMail size={14} />
|
||||||
|
Đơn phép
|
||||||
|
{leaveRequests.length > 0 && (
|
||||||
|
<span className={`ap-btn--leave-badge${hasPendingLeave ? '' : ' muted'}`}>
|
||||||
|
{leaveRequests.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{onClose && (
|
||||||
|
<button type="button" className="ap-btn ap-btn--close" onClick={onClose} title="Đóng">
|
||||||
|
<IconClose size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* ── Collapsible toolbar area ── */}
|
||||||
className={`attendance-qldt-banner${
|
<div className={`ap-collapsible${isCollapsed ? ' collapsed' : ''}`}>
|
||||||
qldtSynced ? (qldtDirty ? ' attendance-qldt-banner--stale' : ' attendance-qldt-banner--synced') : ''
|
{/* Toolbar: date / shift / search */}
|
||||||
}`}
|
<div className="ap-toolbar">
|
||||||
>
|
<label className="ap-field">
|
||||||
{qldtSynced ? (
|
<span>Ngày</span>
|
||||||
qldtDirty ? (
|
<input type="date" className="ap-input search-input" value={date} onChange={e => setDate(e.target.value)} />
|
||||||
<>
|
</label>
|
||||||
<strong>Đã lưu QLĐT — có thay đổi mới</strong>
|
<label className="ap-field">
|
||||||
<span>
|
<span>Ca học</span>
|
||||||
Lần đẩy: {formatQldtTime(shiftInfo?.pushedToQldtAt)} · Cần đẩy lại sau khi chỉnh sửa
|
<select className="ap-input select-filter" value={period} onChange={e => setPeriod(Number(e.target.value))}>
|
||||||
</span>
|
{(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>
|
||||||
<strong>Đã lưu QLĐT</strong>
|
</label>
|
||||||
<span>Lần đẩy gần nhất: {formatQldtTime(shiftInfo?.pushedToQldtAt)}</span>
|
<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>
|
||||||
<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>
|
{/* 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="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" style={{ fontSize: '0.65rem' }}>Ca tắt</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status summary chips */}
|
||||||
|
<div className="ap-chips">
|
||||||
|
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||||
|
<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="ap-chips-total">{filteredRows.length}/{rows.length} SV{searchQuery.trim() ? ' (đã lọc)' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
{currentShift && (
|
{/* ── Leave Requests Modal ── */}
|
||||||
<div className="attendance-shift-info">
|
{showLeaveModal && courseId && (
|
||||||
<span>{currentShift.startTime}–{currentShift.endTime}</span>
|
<div className="modal-overlay" style={{ zIndex: 200 }} onClick={() => setShowLeaveModal(false)}>
|
||||||
<span>{currentShift.courseName || 'Chưa chọn môn'}</span>
|
<div className="modal-container" style={{ maxWidth: '680px', width: '95%', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
|
||||||
{!currentShift.isActive && <span className="badge badge-muted">Ca tắt</span>}
|
<div className="modal-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<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="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 }} />
|
||||||
|
</div>
|
||||||
|
) : leaveRequests.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem 0', color: 'var(--text-muted)', fontSize: '0.85rem', fontStyle: 'italic' }}>
|
||||||
|
Không có đơn xin nghỉ phép nào cho ca học này trong ngày hôm nay.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
leaveRequests.map((req) => (
|
||||||
|
<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={`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="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); }}
|
||||||
|
>
|
||||||
|
<IconCheck size={13} /> Phê duyệt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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); }}
|
||||||
|
>
|
||||||
|
<IconXMark size={13} /> Từ chối
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ap-leave-card-reason">
|
||||||
|
<strong>Lý do nghỉ:</strong> {req.note || 'Không có ghi chú'}
|
||||||
|
</div>
|
||||||
|
{req.reasonImage && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="attendance-status-summary">
|
{/* ── Bulk status actions bar ── */}
|
||||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
{selectedStudentRkIds.length > 0 && (
|
||||||
<button
|
<div className="ap-bulk">
|
||||||
key={opt.value}
|
<span className="ap-bulk-title">Đang chọn {selectedStudentRkIds.length} SV:</span>
|
||||||
type="button"
|
<div className="ap-bulk-btns">
|
||||||
className={`attendance-summary-chip ${attendanceStatusClass(opt.value)}`}
|
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
||||||
onClick={() => setSearchQuery('')}
|
<button key={opt.value} type="button" className={`ap-btn ${attendanceStatusClass(opt.value)}`} style={{ border: '1px solid var(--att-border)' }} onClick={() => handleBulkStatusChange(opt.value)}>
|
||||||
title={`${opt.label}: ${statusCounts[opt.value] ?? 0} sinh viên`}
|
Gắn "{opt.label}"
|
||||||
>
|
</button>
|
||||||
<span className="attendance-summary-chip-label">{opt.short}</span>
|
))}
|
||||||
<span className="attendance-summary-chip-count">{statusCounts[opt.value] ?? 0}</span>
|
</div>
|
||||||
|
<button type="button" className="ap-btn" style={{ marginLeft: 'auto' }} onClick={() => setSelectedStudentRkIds([])}>
|
||||||
|
Hủy chọn
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
<span className="attendance-summary-total">
|
)}
|
||||||
{filteredRows.length}/{rows.length} SV
|
|
||||||
{searchQuery.trim() ? ' (đã lọc)' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="schedule-hint" style={{ margin: 0 }}>
|
{/* ── Attendance table ── */}
|
||||||
Sửa trạng thái thủ công sẽ được khóa — hệ thống tự tính sẽ không ghi đè.
|
<div className="ap-table-scroll attendance-table-scroll table-wrapper" onScroll={handleScroll}>
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="attendance-table-scroll table-wrapper">
|
|
||||||
{loading ? (
|
{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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filteredRows.length > 0 && filteredRows.every(r => selectedStudentRkIds.includes(r.studentRkId))}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
const newIds = new Set(selectedStudentRkIds);
|
||||||
|
filteredRows.forEach(r => newIds.add(r.studentRkId));
|
||||||
|
setSelectedStudentRkIds(Array.from(newIds));
|
||||||
|
} else {
|
||||||
|
const filteredIds = filteredRows.map(r => r.studentRkId);
|
||||||
|
setSelectedStudentRkIds(selectedStudentRkIds.filter(id => !filteredIds.includes(id)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
<th>Sinh viên</th>
|
<th>Sinh viên</th>
|
||||||
<th>Online</th>
|
<th>Online</th>
|
||||||
<th>Trạng thái</th>
|
<th>Trạng thái</th>
|
||||||
@@ -229,53 +631,51 @@ export const AttendancePanel: React.FC<AttendancePanelProps> = ({ classId }) =>
|
|||||||
<tbody>
|
<tbody>
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} 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'}
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredRows.map(row => (
|
filteredRows.map(row => {
|
||||||
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)}>
|
const isSelected = selectedStudentRkIds.includes(row.studentRkId);
|
||||||
<td>
|
return (
|
||||||
<div className="attendance-student-name">{row.fullName}</div>
|
<tr key={row.studentRkId} className={attendanceStatusClass(row.status)} style={{ background: isSelected ? 'rgba(var(--accent-rgb, 99 102 241) / 0.05)' : undefined }}>
|
||||||
<div className="attendance-student-meta">
|
<td>
|
||||||
<code>{row.studentCode}</code>
|
<input type="checkbox" checked={isSelected} onChange={() => toggleStudentSelection(row.studentRkId)} />
|
||||||
{row.email && <span>{row.email}</span>}
|
</td>
|
||||||
</div>
|
<td style={{ cursor: 'pointer' }} onClick={() => toggleStudentSelection(row.studentRkId)}>
|
||||||
</td>
|
<div className="attendance-student-name" style={{ fontWeight: 700, fontSize: '0.85rem' }}>{row.fullName}</div>
|
||||||
<td>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem 0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '1px' }}>
|
||||||
<span className="attendance-online-mins">{row.onlineMinutes}</span>
|
<code>{row.studentCode}</code>
|
||||||
<span className="attendance-online-unit">phút</span>
|
{row.email && <span>{row.email}</span>}
|
||||||
</td>
|
</div>
|
||||||
<td>
|
</td>
|
||||||
<select
|
<td>
|
||||||
className={`attendance-status-select ${attendanceStatusClass(row.status)}`}
|
<span style={{ fontFamily: 'monospace', fontWeight: 800, fontSize: '0.95rem' }}>{row.onlineMinutes}</span>
|
||||||
value={row.status}
|
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', marginLeft: '2px' }}>phút</span>
|
||||||
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
|
</td>
|
||||||
>
|
<td>
|
||||||
{ATTENDANCE_STATUS_OPTIONS.map(opt => (
|
<select
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
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' }}
|
||||||
</select>
|
value={row.status}
|
||||||
</td>
|
onChange={e => handleStatusChange(row.studentRkId, Number(e.target.value))}
|
||||||
<td>
|
>
|
||||||
<div className="attendance-notes">
|
{ATTENDANCE_STATUS_OPTIONS.map(opt => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
||||||
{row.pushedToQldtAt ? (
|
</select>
|
||||||
<span className="attendance-qldt-tag attendance-qldt-tag--ok" title={row.pushedToQldtAt}>
|
</td>
|
||||||
QLĐT ✓
|
<td>
|
||||||
</span>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
||||||
) : (
|
{row.pushedToQldtAt
|
||||||
<span className="attendance-qldt-tag attendance-qldt-tag--pending">Chưa QLĐT</span>
|
? <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="attendance-lock-tag" title="Giáo viên đã sửa — không bị ghi đè">
|
{row.statusEditedByTeacher && <span className="ap-tag ap-tag--locked">Đã khóa</span>}
|
||||||
Đã khóa
|
</div>
|
||||||
</span>
|
</td>
|
||||||
)}
|
</tr>
|
||||||
</div>
|
);
|
||||||
</td>
|
})
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api';
|
||||||
import { type StaffChatOpenDetail } from '../chatEvents';
|
import { type StaffChatOpenDetail } from '../chatEvents';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket';
|
import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket';
|
||||||
|
|
||||||
export function ChatWidget() {
|
export function ChatWidget() {
|
||||||
const { staff } = useAuth();
|
const { staff } = useAuth();
|
||||||
@@ -83,6 +83,13 @@ export function ChatWidget() {
|
|||||||
return () => { off(); };
|
return () => { off(); };
|
||||||
}, [handleIncoming]);
|
}, [handleIncoming]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return onStudentViolation((v) => {
|
||||||
|
const name = v.studentName || v.studentCode || `SV #${v.studentId}`;
|
||||||
|
showToast(`⚠ ${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpen = (e: Event) => {
|
const onOpen = (e: Event) => {
|
||||||
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
|
const detail = (e as CustomEvent<StaffChatOpenDetail>).detail;
|
||||||
@@ -152,7 +159,7 @@ export function ChatWidget() {
|
|||||||
const dock = (
|
const dock = (
|
||||||
<div className="chat-dock">
|
<div className="chat-dock">
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className="chat-toast" role="status">
|
<div className={`chat-toast ${toast.title.startsWith('⚠') ? 'chat-toast-alert' : ''}`} role="status">
|
||||||
<strong>{toast.title}</strong>
|
<strong>{toast.title}</strong>
|
||||||
<span>{toast.body}</span>
|
<span>{toast.body}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,73 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api } from '../api';
|
import { api, type GuideLinkItem } from '../api';
|
||||||
import type { StatsResponse } from '../api';
|
import type { StatsResponse } from '../api';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
interface DashboardTabProps {
|
interface DashboardTabProps {
|
||||||
onNavigate: (tab: string) => void;
|
onNavigate: (tab: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IconClasses = () => (
|
||||||
|
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconActiveClasses = () => (
|
||||||
|
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconStudents = () => (
|
||||||
|
<svg viewBox="0 0 24 24" width="28" height="28" 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconAlert = () => (
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
||||||
|
const { staff } = useAuth();
|
||||||
|
const isSuperAdmin = staff?.email === 'phuocntb@rikkeiacademy.com';
|
||||||
|
|
||||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Guide links (teacher guides) state
|
||||||
|
const [links, setLinks] = useState<GuideLinkItem[]>([]);
|
||||||
|
const [linksLoading, setLinksLoading] = useState(true);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingLink, setEditingLink] = useState<GuideLinkItem | null>(null);
|
||||||
|
const [modalTitle, setModalTitle] = useState('');
|
||||||
|
const [modalUrl, setModalUrl] = useState('');
|
||||||
|
const [modalSaving, setModalSaving] = useState(false);
|
||||||
|
|
||||||
|
const fetchLinks = async () => {
|
||||||
|
try {
|
||||||
|
setLinksLoading(true);
|
||||||
|
const res = await api.listGuideLinks();
|
||||||
|
setLinks(res.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Không thể tải tài liệu hướng dẫn giáo viên:', err);
|
||||||
|
} finally {
|
||||||
|
setLinksLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -24,8 +81,55 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchStats();
|
fetchStats();
|
||||||
|
fetchLinks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenAddModal = () => {
|
||||||
|
setEditingLink(null);
|
||||||
|
setModalTitle('');
|
||||||
|
setModalUrl('');
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (link: GuideLinkItem) => {
|
||||||
|
setEditingLink(link);
|
||||||
|
setModalTitle(link.title);
|
||||||
|
setModalUrl(link.url);
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveLink = async () => {
|
||||||
|
if (!modalTitle.trim() || !modalUrl.trim()) {
|
||||||
|
alert('Vui lòng điền đầy đủ tiêu đề và đường dẫn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setModalSaving(true);
|
||||||
|
if (editingLink) {
|
||||||
|
await api.updateGuideLink(editingLink.id, modalTitle.trim(), modalUrl.trim());
|
||||||
|
} else {
|
||||||
|
await api.createGuideLink(modalTitle.trim(), modalUrl.trim());
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
await fetchLinks();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Lưu tài liệu thất bại');
|
||||||
|
} finally {
|
||||||
|
setModalSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLink = async (id: number, title: string) => {
|
||||||
|
if (window.confirm(`Bạn có chắc chắn muốn xóa tài liệu giáo viên "${title}"?`)) {
|
||||||
|
try {
|
||||||
|
await api.deleteGuideLink(id);
|
||||||
|
await fetchLinks();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Xóa tài liệu thất bại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-page">
|
<div className="tab-page">
|
||||||
<div className="tab-page-toolbar">
|
<div className="tab-page-toolbar">
|
||||||
@@ -38,86 +142,249 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-page-body tab-page-scroll">
|
<div className="tab-page-body tab-page-scroll">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
|
<div className="sync-spinner" style={{ width: '40px', height: '40px', borderWidth: '3px' }}></div>
|
||||||
<p style={{ marginTop: '1rem' }}>Đang tải thông số hệ thống...</p>
|
<p style={{ marginTop: '1rem' }}>Đang tải thông số hệ thống...</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="empty-state" style={{ border: '1px solid rgba(239, 68, 68, 0.2)', backgroundColor: 'rgba(239, 68, 68, 0.05)' }}>
|
|
||||||
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}>⚠️</div>
|
|
||||||
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && stats && (
|
|
||||||
<>
|
|
||||||
<div className="stats-grid">
|
|
||||||
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
|
||||||
<div>
|
|
||||||
<div className="stat-label">Tổng lớp học đã đồng bộ</div>
|
|
||||||
<div className="stat-value">{stats.totalClasses}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-icon">🏫</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
|
||||||
<div>
|
|
||||||
<div className="stat-label">Lớp đang giảng dạy</div>
|
|
||||||
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-icon success">✓</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
|
|
||||||
<div>
|
|
||||||
<div className="stat-label">Tổng sinh viên đã đồng bộ</div>
|
|
||||||
<div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-icon">🎓</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="content-grid-2">
|
{error && (
|
||||||
<div className="content-card">
|
<div className="empty-state" style={{ border: '1px solid rgba(214, 48, 49, 0.2)', backgroundColor: 'rgba(214, 48, 49, 0.05)', borderRadius: '12px' }}>
|
||||||
<h2>Khởi động nhanh</h2>
|
<div className="empty-state-icon" style={{ color: 'var(--danger)' }}>
|
||||||
<p>
|
<IconAlert />
|
||||||
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
|
</div>
|
||||||
Hệ thống hỗ trợ đồng bộ hóa thông tin tự động từ cổng đào tạo chính (QLĐT), giúp lưu trữ, cập nhật trạng thái lớp học và danh sách sinh viên nội bộ.
|
<p style={{ color: 'var(--danger)', fontWeight: 600 }}>Lỗi: {error}</p>
|
||||||
</p>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
)}
|
||||||
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
|
|
||||||
Quản lý lớp học
|
{!loading && !error && stats && (
|
||||||
</button>
|
<>
|
||||||
<button className="btn btn-secondary" onClick={() => onNavigate('students')}>
|
<div className="stats-grid">
|
||||||
Danh sách sinh viên
|
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
||||||
</button>
|
<div>
|
||||||
|
<div className="stat-label">Tổng lớp học đã đồng bộ</div>
|
||||||
|
<div className="stat-value">{stats.totalClasses}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-icon" style={{ color: 'var(--accent)' }}>
|
||||||
|
<IconClasses />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card" onClick={() => onNavigate('classes')} style={{ cursor: 'pointer' }}>
|
||||||
|
<div>
|
||||||
|
<div className="stat-label">Lớp đang giảng dạy</div>
|
||||||
|
<div className="stat-value" style={{ color: 'var(--success)' }}>{stats.activeClasses}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-icon success" style={{ color: 'var(--success)', backgroundColor: 'var(--success-light)' }}>
|
||||||
|
<IconActiveClasses />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card" onClick={() => onNavigate('students')} style={{ cursor: 'pointer' }}>
|
||||||
|
<div>
|
||||||
|
<div className="stat-label">Tổng sinh viên đã đồng bộ</div>
|
||||||
|
<div className="stat-value" style={{ color: 'var(--accent-hover)' }}>{stats.totalStudents}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-icon" style={{ color: 'var(--accent-hover)' }}>
|
||||||
|
<IconStudents />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content-card">
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
<h2>Quy trình hoạt động</h2>
|
<div className="content-card">
|
||||||
<ul style={{
|
<h2>Khởi động nhanh</h2>
|
||||||
color: 'var(--text-secondary)',
|
<p style={{ lineHeight: '1.6', color: 'var(--text-secondary)' }}>
|
||||||
fontSize: '0.875rem',
|
Chào mừng bạn đến với trang quản trị <strong>Simple Care</strong>.
|
||||||
lineHeight: 1.75,
|
Hệ thống hỗ trợ quản lý lớp học, thi cử và chăm sóc sinh viên.
|
||||||
paddingLeft: '1.15rem',
|
</p>
|
||||||
display: 'flex',
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
||||||
flexDirection: 'column',
|
<button className="btn btn-primary" onClick={() => onNavigate('classes')}>
|
||||||
gap: '0.5rem',
|
Quản lý lớp học
|
||||||
margin: 0
|
</button>
|
||||||
}}>
|
<button className="btn btn-secondary" onClick={() => onNavigate('learning')}>
|
||||||
<li><strong>Đồng bộ lớp học:</strong> Tải danh sách lớp và môn học, kéo thông tin sinh viên từng lớp qua bảng kết quả.</li>
|
Lịch học & Giám sát
|
||||||
<li><strong>Quản lý lớp:</strong> Lọc theo phân hệ, tìm kiếm tên/mã lớp và bật/tắt cờ <strong>Đang học</strong>.</li>
|
</button>
|
||||||
<li><strong>Đồng bộ sinh viên:</strong> Kéo toàn bộ sinh viên từ hệ thống chính (kèm thông tin liên hệ, hệ học) vào database nội bộ.</li>
|
<button className="btn btn-secondary" onClick={() => onNavigate('exams')}>
|
||||||
</ul>
|
Quản lý phòng thi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
<div className="content-card" style={{ marginTop: '1.5rem' }}>
|
||||||
)}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', borderBottom: '1px solid var(--border-color)', paddingBottom: '0.75rem' }}>
|
||||||
|
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--accent)' }}>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
Tài liệu nội bộ (Giáo viên)
|
||||||
|
</h2>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleOpenAddModal}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
||||||
|
>
|
||||||
|
+ Thêm tài liệu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linksLoading && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem' }}>
|
||||||
|
<div className="sync-spinner" style={{ width: '24px', height: '24px' }}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!linksLoading && links.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic', fontSize: '0.9rem', margin: '0.5rem 0' }}>Chưa có tài liệu hướng dẫn nào được cấu hình.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!linksLoading && links.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{links.map((link) => (
|
||||||
|
<div
|
||||||
|
key={link.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
backgroundColor: 'var(--bg-app)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.25)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#fff';
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-sm)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||||||
|
e.currentTarget.style.backgroundColor = 'var(--bg-app)';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.92rem',
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--accent)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#4285F4' }}>
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
</svg>
|
||||||
|
{link.title}
|
||||||
|
</a>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleOpenEditModal(link)}
|
||||||
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleDeleteLink(link.id, link.title)}
|
||||||
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||||
|
<div className="modal-container" style={{ maxWidth: '450px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 className="modal-title">{editingLink ? 'Cập nhật tài liệu' : 'Thêm tài liệu hướng dẫn'}</h2>
|
||||||
|
<button className="modal-close-btn" onClick={() => setShowModal(false)} aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Tiêu đề tài liệu</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalTitle}
|
||||||
|
onChange={e => setModalTitle(e.target.value)}
|
||||||
|
placeholder="VD: Hướng dẫn coi thi cuối kỳ"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.55rem 0.75rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="login-field" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-secondary)' }}>Đường dẫn Google Docs</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={modalUrl}
|
||||||
|
onChange={e => setModalUrl(e.target.value)}
|
||||||
|
placeholder="https://docs.google.com/document/d/..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.55rem 0.75rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'var(--transition)'
|
||||||
|
}}
|
||||||
|
onFocus={e => e.target.style.borderColor = 'var(--accent)'}
|
||||||
|
onBlur={e => e.target.style.borderColor = 'var(--border-color)'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ padding: '1rem 1.5rem' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Hủy</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSaveLink}
|
||||||
|
disabled={modalSaving}
|
||||||
|
>
|
||||||
|
{modalSaving ? 'Đang lưu...' : 'Lưu lại'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
392
management/src/components/ExamGridProctor.tsx
Normal file
392
management/src/components/ExamGridProctor.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
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[];
|
||||||
|
onlineIds: number[];
|
||||||
|
onSelectStudent: (student: ExamRoomStudent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
||||||
|
students,
|
||||||
|
onlineIds,
|
||||||
|
onSelectStudent,
|
||||||
|
}) => {
|
||||||
|
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 gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setZoomedStudent(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredStudents = useMemo(() => {
|
||||||
|
return students.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
s.studentCode.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const isOnline = onlineIds.includes(s.studentRkId);
|
||||||
|
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({
|
||||||
|
studentRkId: s.studentRkId,
|
||||||
|
fullName: s.fullName,
|
||||||
|
studentCode: s.studentCode,
|
||||||
|
email: s.email,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFsChange = () => {
|
||||||
|
setIsFullscreen(document.fullscreenElement === gridContainerRef.current);
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handleFsChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFsChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
const el = gridContainerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (document.fullscreenElement === el) {
|
||||||
|
document.exitFullscreen().catch(console.error);
|
||||||
|
} else {
|
||||||
|
el.requestFullscreen().catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`grid-proctor-container ${isFullscreen ? 'fullscreen-active' : ''}`} ref={gridContainerRef}>
|
||||||
|
<div className="grid-proctor-toolbar">
|
||||||
|
<div className="grid-proctor-toolbar-left">
|
||||||
|
<div className="gp-search">
|
||||||
|
<span className="gp-search-icon"><IconSearch size={14} /></span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="search-input gp-search-input"
|
||||||
|
placeholder="Lọc sinh viên..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="gp-check">
|
||||||
|
<input type="checkbox" checked={onlyOnline} onChange={(e) => setOnlyOnline(e.target.checked)} />
|
||||||
|
<span>Chỉ hiện Online</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`grid-proctor-card ${isOnline ? 'online' : 'offline'} ${s.submitted ? 'submitted' : ''}`}
|
||||||
|
onClick={() => onSelectStudent(s)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<span className="student-code">{s.studentCode}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid-proctor-card-body">
|
||||||
|
{isOnline ? (
|
||||||
|
<div
|
||||||
|
className="proctor-frame-container cursor-zoom-in"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setZoomedStudent(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StudentStreamImage
|
||||||
|
studentId={s.studentRkId}
|
||||||
|
kind="screen"
|
||||||
|
className="proctor-screen-image"
|
||||||
|
/>
|
||||||
|
{showWebcamOverlay && (
|
||||||
|
<div className="proctor-webcam-overlay">
|
||||||
|
<StudentStreamImage
|
||||||
|
studentId={s.studentRkId}
|
||||||
|
kind="webcam"
|
||||||
|
className="proctor-webcam-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="proctor-placeholder offline">
|
||||||
|
<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 đề'}>
|
||||||
|
<IconFile size={12} />
|
||||||
|
{s.paperTitle ? s.paperTitle : 'Chưa gán đề'}
|
||||||
|
</span>
|
||||||
|
<div className="footer-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gp-action-btn"
|
||||||
|
onClick={(e) => handleOpenChat(s, e)}
|
||||||
|
title="Nhắn tin cho sinh viên"
|
||||||
|
>
|
||||||
|
<IconMessage size={13} />
|
||||||
|
Nhắn tin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gp-action-btn gp-action-btn--primary"
|
||||||
|
onClick={() => onSelectStudent(s)}
|
||||||
|
title="Xem chi tiết giám sát"
|
||||||
|
>
|
||||||
|
<IconEye size={13} />
|
||||||
|
Giám sát
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredStudents.length === 0 && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
management/src/components/ExamPaperPdfModal.tsx
Normal file
44
management/src/components/ExamPaperPdfModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { renderExamPdfPages } from '../utils/renderExamPdf';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
loading?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExamPaperPdfModal({ title, bytes, loading, onClose }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bytes || !containerRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setErr('');
|
||||||
|
renderExamPdfPages(containerRef.current, bytes).catch((e: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'Không hiển thị được PDF');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [bytes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={onClose}>
|
||||||
|
<div className="exam-pdf-viewer-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header exam-pdf-viewer-header">
|
||||||
|
<h2 className="modal-title">{title}</h2>
|
||||||
|
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div className="exam-pdf-viewer-scroll" ref={containerRef}>
|
||||||
|
{loading && <p className="exam-pdf-viewer-status">Đang tải đề...</p>}
|
||||||
|
{err && <p className="login-error" style={{ padding: '1rem' }}>{err}</p>}
|
||||||
|
{!loading && !bytes && !err && (
|
||||||
|
<p className="exam-pdf-viewer-status">Không có dữ liệu PDF</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { apiExam, type ExamRoomItem } from '../api';
|
import { apiExam, type ExamRoomItem } from '../api';
|
||||||
import { openExam } from './NavHistoryBar';
|
import { openExam } from './NavHistoryBar';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
try {
|
try {
|
||||||
@@ -10,13 +11,13 @@ function fmtTime(iso: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(st: string) {
|
function statusMeta(st: string) {
|
||||||
if (st === 'draft') return { text: 'Tạm thời', cls: 'badge-muted' };
|
if (st === 'draft') return { text: 'Tạm thời', tone: 'muted' };
|
||||||
if (st === 'ready') return { text: 'Sẵn sàng', cls: 'badge-info' };
|
if (st === 'ready') return { text: 'Sẵn sàng', tone: 'info' };
|
||||||
if (st === 'active') return { text: 'Đang thi', cls: 'badge-success' };
|
if (st === 'active') return { text: 'Đang thi', tone: 'active' };
|
||||||
if (st === 'cancelled') return { text: 'Đã hủy', cls: 'badge-warning' };
|
if (st === 'cancelled') return { text: 'Đã hủy', tone: 'warn' };
|
||||||
if (st === 'ended') return { text: 'Đã kết thúc', cls: 'badge-muted' };
|
if (st === 'ended') return { text: 'Đã kết thúc', tone: 'muted' };
|
||||||
return { text: st, cls: 'badge-muted' };
|
return { text: st, tone: 'muted' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalInput(iso?: string) {
|
function toLocalInput(iso?: string) {
|
||||||
@@ -31,7 +32,71 @@ function localInputToISO(v: string) {
|
|||||||
return new Date(v).toISOString();
|
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 = () => {
|
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 [rooms, setRooms] = useState<ExamRoomItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@@ -39,6 +104,8 @@ export const ExamsTab: React.FC = () => {
|
|||||||
const [start, setStart] = useState('');
|
const [start, setStart] = useState('');
|
||||||
const [end, setEnd] = useState('');
|
const [end, setEnd] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roomScope, setRoomScope] = useState<RoomScope>('mine');
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -52,7 +119,32 @@ export const ExamsTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => {
|
||||||
|
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 = 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: scopedRooms.length, active, upcoming };
|
||||||
|
}, [scopedRooms]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
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 () => {
|
const create = async () => {
|
||||||
if (!name.trim() || !start || !end) return;
|
if (!name.trim() || !start || !end) return;
|
||||||
@@ -65,6 +157,9 @@ export const ExamsTab: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setName('');
|
setName('');
|
||||||
|
setStart('');
|
||||||
|
setEnd('');
|
||||||
|
await load();
|
||||||
openExam(room.id, room.name);
|
openExam(room.id, room.name);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message || 'Lỗi');
|
alert(e?.message || 'Lỗi');
|
||||||
@@ -73,75 +168,578 @@ 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.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await apiExam.remove(roomId);
|
||||||
|
alert('Đã xóa phòng thi thành công!');
|
||||||
|
await load();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Xóa phòng thi thất bại');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="tab-page exams-page">
|
||||||
<header className="page-header page-header--row">
|
<div className="tab-page-toolbar">
|
||||||
<div>
|
<div className="page-header">
|
||||||
<h1 className="page-title">Phòng thi</h1>
|
<div className="page-title">
|
||||||
<p className="page-desc">Tạo phòng thi, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
<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)}>
|
||||||
|
<IconPlus />
|
||||||
|
Tạo phòng thi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>+ Tạo phòng thi</button>
|
</div>
|
||||||
</header>
|
|
||||||
|
<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">
|
||||||
|
{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>
|
||||||
|
<span className="learning-stat-label">Đang thi</span>
|
||||||
|
</div>
|
||||||
|
<div className="learning-stat">
|
||||||
|
<span className="learning-stat-value">{stats.upcoming}</span>
|
||||||
|
<span className="learning-stat-label">Sắp / tạm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 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 && (
|
{showCreate && (
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
<div className="modal-overlay" onClick={() => setShowCreate(false)}>
|
||||||
<h2 className="section-title">Phòng thi mới</h2>
|
<div className="modal-container class-picker-modal exam-create-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="form-grid" style={{ maxWidth: 520 }}>
|
<div className="modal-header class-picker-header">
|
||||||
<label className="login-field">
|
<div>
|
||||||
<span>Tên phòng thi</span>
|
<h2 className="modal-title">Tạo phòng thi mới</h2>
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
|
<p className="class-picker-subtitle">
|
||||||
</label>
|
Đặt tên và khung giờ thi. Sau khi tạo sẽ mở workspace để cấu hình đề và sinh viên.
|
||||||
<label className="login-field">
|
</p>
|
||||||
<span>Bắt đầu</span>
|
</div>
|
||||||
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} />
|
<button className="modal-close-btn" onClick={() => setShowCreate(false)} aria-label="Đóng">
|
||||||
</label>
|
×
|
||||||
<label className="login-field">
|
</button>
|
||||||
<span>Kết thúc</span>
|
</div>
|
||||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
|
<div className="modal-body class-picker-body exam-create-body">
|
||||||
</label>
|
<label className="exam-field">
|
||||||
</div>
|
<span className="exam-field-label">Tên phòng thi</span>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
<input
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
|
className="search-input"
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="VD: Thi cuối kỳ Java"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<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 exam-datetime"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => setStart(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="exam-field">
|
||||||
|
<span className="exam-field-label">Kết thúc</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="search-input exam-datetime"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => setEnd(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{ gap: '0.65rem' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setShowCreate(false)}>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card table-card">
|
<style>{`
|
||||||
{loading ? (
|
.page-title-heading {
|
||||||
<div className="table-empty">Đang tải...</div>
|
display: flex;
|
||||||
) : rooms.length === 0 ? (
|
align-items: center;
|
||||||
<div className="table-empty">Chưa có phòng thi nào.</div>
|
gap: 0.55rem;
|
||||||
) : (
|
}
|
||||||
<table className="data-table">
|
.page-title-icon {
|
||||||
<thead>
|
display: inline-flex;
|
||||||
<tr>
|
align-items: center;
|
||||||
<th>Tên</th>
|
justify-content: center;
|
||||||
<th>Thời gian</th>
|
color: var(--accent);
|
||||||
<th>SV / Đề</th>
|
flex-shrink: 0;
|
||||||
<th>Trạng thái</th>
|
}
|
||||||
<th></th>
|
|
||||||
</tr>
|
.exams-header-actions {
|
||||||
</thead>
|
display: flex;
|
||||||
<tbody>
|
gap: 0.5rem;
|
||||||
{rooms.map((r) => {
|
flex-wrap: wrap;
|
||||||
const st = statusLabel(r.displayStatus || r.status);
|
justify-content: flex-end;
|
||||||
return (
|
}
|
||||||
<tr key={r.id}>
|
.exams-header-actions .btn {
|
||||||
<td><strong>{r.name}</strong></td>
|
display: inline-flex;
|
||||||
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} — {fmtTime(r.endTime)}</td>
|
align-items: center;
|
||||||
<td>{r.studentCount} SV · {r.paperCount} đề</td>
|
gap: 0.4rem;
|
||||||
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
|
}
|
||||||
<td>
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
|
.exams-toolbar-card {
|
||||||
</td>
|
margin-bottom: 0 !important;
|
||||||
</tr>
|
}
|
||||||
);
|
.exams-toolbar-inner {
|
||||||
})}
|
margin: 0;
|
||||||
</tbody>
|
}
|
||||||
</table>
|
.exams-search {
|
||||||
)}
|
max-width: none !important;
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
.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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
186
management/src/components/GitHubReposPanel.tsx
Normal file
186
management/src/components/GitHubReposPanel.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiGitHubRepos, type GitHubRepoItem } from '../api';
|
||||||
|
|
||||||
|
export function GitHubReposPanel({ connected, canDeleteRepos = true }: { connected: boolean; canDeleteRepos?: boolean }) {
|
||||||
|
const [repos, setRepos] = useState<GitHubRepoItem[]>([]);
|
||||||
|
const [githubLogin, setGithubLogin] = useState('');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!connected) {
|
||||||
|
setRepos([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const res = await apiGitHubRepos.list();
|
||||||
|
setRepos(res.data || []);
|
||||||
|
setGithubLogin(res.githubLogin || '');
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Không tải repo');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [connected]);
|
||||||
|
|
||||||
|
useEffect(() => { load().catch(console.error); }, [load]);
|
||||||
|
|
||||||
|
const toggle = (key: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (selected.size === repos.length) setSelected(new Set());
|
||||||
|
else setSelected(new Set(repos.map((r) => r.key)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
if (!canDeleteRepos) {
|
||||||
|
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Xóa ${selected.size} repo trên GitHub? Hành động không hoàn tác.`)) return;
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiGitHubRepos.bulkDelete([...selected]);
|
||||||
|
if (res.deleted > 0) {
|
||||||
|
setMsg(res.message || `Đã xóa ${res.deleted} repo`);
|
||||||
|
if (res.failures?.length) setErr(res.failures.join('; '));
|
||||||
|
} else {
|
||||||
|
setMsg('');
|
||||||
|
setErr(res.failures?.join('; ') || 'Không xóa được repo nào — thử ngắt kết nối rồi kết nối lại GitHub.');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Xóa thất bại');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteOne = async (repo: GitHubRepoItem) => {
|
||||||
|
if (!canDeleteRepos) {
|
||||||
|
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Xóa repo "${repo.repoName}" trên GitHub?`)) return;
|
||||||
|
setErr(''); setMsg('');
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await apiGitHubRepos.bulkDelete([repo.key]);
|
||||||
|
if (res.deleted > 0) {
|
||||||
|
setMsg(res.message || 'Đã xóa repo');
|
||||||
|
} else {
|
||||||
|
setMsg('');
|
||||||
|
setErr(res.failures?.join('; ') || 'Không xóa được repo — thử ngắt kết nối rồi kết nối lại GitHub.');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Xóa thất bại');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!connected) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="github-repos-panel">
|
||||||
|
<p className="exam-git-hint" style={{ marginTop: 0 }}>
|
||||||
|
Toàn bộ repo trên tài khoản GitHub{githubLogin ? ` @${githubLogin}` : ''} — có thể chọn và xóa nhiều repo cùng lúc.
|
||||||
|
</p>
|
||||||
|
<div className="github-repos-toolbar">
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" disabled={loading} onClick={() => load()}>
|
||||||
|
{loading ? 'Đang tải từ GitHub...' : 'Làm mới'}
|
||||||
|
</button>
|
||||||
|
{repos.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={toggleAll}>
|
||||||
|
{selected.size === repos.length ? 'Bỏ chọn' : 'Chọn tất cả'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||||
|
disabled={deleting || selected.size === 0 || !canDeleteRepos}
|
||||||
|
onClick={deleteSelected}
|
||||||
|
>
|
||||||
|
{deleting ? 'Đang xóa...' : `Xóa đã chọn (${selected.size})`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{err && <div className="login-error">{err}</div>}
|
||||||
|
{msg && <div className="login-success">{msg}</div>}
|
||||||
|
{repos.length === 0 && !loading ? (
|
||||||
|
<p className="exam-git-hint">Không có repo nào trên tài khoản GitHub (hoặc token thiếu quyền đọc repo).</p>
|
||||||
|
) : (
|
||||||
|
<div className="github-repos-table-wrap table-wrapper">
|
||||||
|
<table className="data-table github-repos-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 36 }} />
|
||||||
|
<th>Repo</th>
|
||||||
|
<th>Loại</th>
|
||||||
|
<th>Phòng / SV</th>
|
||||||
|
<th>Cập nhật</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{repos.map((r) => (
|
||||||
|
<tr key={r.key}>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" checked={selected.has(r.key)} onChange={() => toggle(r.key)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={r.repoHtmlUrl} target="_blank" rel="noreferrer" className="github-repo-link">
|
||||||
|
{r.repoName}
|
||||||
|
</a>
|
||||||
|
{r.private && <span className="badge badge-muted" style={{ marginLeft: 6 }}>private</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{r.publishMode === 'student' ? (
|
||||||
|
<span className="badge badge-info">Từng SV</span>
|
||||||
|
) : r.publishMode === 'room' ? (
|
||||||
|
<span className="badge badge-muted">Gộp phòng</span>
|
||||||
|
) : r.fromSimpleCare ? (
|
||||||
|
<span className="badge badge-muted">Simple Care</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-muted">Khác</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: '0.85rem' }}>
|
||||||
|
{r.examRoomName && <div>{r.examRoomName}</div>}
|
||||||
|
{r.studentLabel && <div className="text-muted">{r.studentLabel}</div>}
|
||||||
|
{!r.examRoomName && !r.studentLabel && '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||||
|
{r.updatedAt ? new Date(r.updatedAt).toLocaleString('vi-VN') : new Date(r.createdAt).toLocaleString('vi-VN')}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" disabled={deleting || !canDeleteRepos} onClick={() => deleteOne(r)}>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
TAB_LABELS,
|
TAB_LABELS,
|
||||||
|
SYSTEM_SECTION_LABELS,
|
||||||
type NavEntry,
|
type NavEntry,
|
||||||
type TabId,
|
type TabId,
|
||||||
parseRoute,
|
parseRoute,
|
||||||
@@ -24,7 +25,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
return () => window.removeEventListener('popstate', refresh);
|
return () => window.removeEventListener('popstate', refresh);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentTabLabel = TAB_LABELS[route.tab];
|
const currentTabLabel =
|
||||||
|
route.tab === 'system'
|
||||||
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[route.systemSection]}`
|
||||||
|
: TAB_LABELS[route.tab];
|
||||||
const recent = history.slice(0, 6);
|
const recent = history.slice(0, 6);
|
||||||
|
|
||||||
const handlePillClick = (entry: NavEntry) => {
|
const handlePillClick = (entry: NavEntry) => {
|
||||||
@@ -32,6 +36,8 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
navigate(entry.tab, entry.classId, entry.label, 'class');
|
navigate(entry.tab, entry.classId, entry.label, 'class');
|
||||||
} else if (entry.kind === 'exam' && entry.examId) {
|
} else if (entry.kind === 'exam' && entry.examId) {
|
||||||
navigate(entry.tab, entry.examId, entry.label, 'exam');
|
navigate(entry.tab, entry.examId, entry.label, 'exam');
|
||||||
|
} else if (entry.tab === 'system' && entry.systemSection) {
|
||||||
|
navigate('system', null, undefined, 'class', entry.systemSection);
|
||||||
} else {
|
} else {
|
||||||
navigate(entry.tab);
|
navigate(entry.tab);
|
||||||
}
|
}
|
||||||
@@ -44,6 +50,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
if (entry.kind === 'exam' && route.examId) {
|
if (entry.kind === 'exam' && route.examId) {
|
||||||
return entry.examId === route.examId;
|
return entry.examId === route.examId;
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'tab' && route.tab === 'system') {
|
||||||
|
return !route.classId && !route.examId && entry.tab === 'system' && entry.systemSection === route.systemSection;
|
||||||
|
}
|
||||||
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
|
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +70,13 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
Simple Care
|
Simple Care
|
||||||
</button>
|
</button>
|
||||||
<span className="breadcrumb-sep">/</span>
|
<span className="breadcrumb-sep">/</span>
|
||||||
<button type="button" className="breadcrumb-link" onClick={() => navigate(route.tab)}>
|
<button type="button" className="breadcrumb-link" onClick={() => {
|
||||||
|
if (route.tab === 'system') {
|
||||||
|
navigate('system', null, undefined, 'class', route.systemSection);
|
||||||
|
} else {
|
||||||
|
navigate(route.tab);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
{currentTabLabel}
|
{currentTabLabel}
|
||||||
</button>
|
</button>
|
||||||
{route.classId && classLabel && (
|
{route.classId && classLabel && (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function formatBssid(raw: string): string | null {
|
|||||||
return key.match(/.{2}/g)!.join(':');
|
return key.match(/.{2}/g)!.join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NetworkTab: React.FC = () => {
|
export const NetworkSection: React.FC = () => {
|
||||||
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
|
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,8 +31,8 @@ export const NetworkTab: React.FC = () => {
|
|||||||
const res = await apiFetchAcceptedWifis();
|
const res = await apiFetchAcceptedWifis();
|
||||||
setAcceptedItems(
|
setAcceptedItems(
|
||||||
(res.data || [])
|
(res.data || [])
|
||||||
.filter(r => r.ssid && r.bssid)
|
.filter((r) => r.ssid && r.bssid)
|
||||||
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
|
.map((r) => ({ ssid: r.ssid, bssid: r.bssid })),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setAcceptedItems([]);
|
setAcceptedItems([]);
|
||||||
@@ -50,15 +50,15 @@ export const NetworkTab: React.FC = () => {
|
|||||||
const trimmedBssid = bssid.trim();
|
const trimmedBssid = bssid.trim();
|
||||||
const key = bssidKey(trimmedBssid);
|
const key = bssidKey(trimmedBssid);
|
||||||
if (!trimmedSsid || key.length !== 12) return;
|
if (!trimmedSsid || key.length !== 12) return;
|
||||||
setAcceptedItems(prev => {
|
setAcceptedItems((prev) => {
|
||||||
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
|
if (prev.some((item) => bssidKey(item.bssid) === key)) return prev;
|
||||||
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
|
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeWifi = (bssid: string) => {
|
const removeWifi = (bssid: string) => {
|
||||||
const key = bssidKey(bssid);
|
const key = bssidKey(bssid);
|
||||||
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
|
setAcceptedItems((prev) => prev.filter((item) => bssidKey(item.bssid) !== key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManualAdd = () => {
|
const handleManualAdd = () => {
|
||||||
@@ -73,11 +73,11 @@ export const NetworkTab: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = bssidKey(bssid);
|
const key = bssidKey(bssid);
|
||||||
if (acceptedItems.some(item => bssidKey(item.bssid) === key)) {
|
if (acceptedItems.some((item) => bssidKey(item.bssid) === key)) {
|
||||||
setManualError('BSSID này đã có trong danh sách.');
|
setManualError('BSSID này đã có trong danh sách.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
|
setAcceptedItems((prev) => [...prev, { ssid, bssid }]);
|
||||||
setManualSsid('');
|
setManualSsid('');
|
||||||
setManualBssid('');
|
setManualBssid('');
|
||||||
setManualError(null);
|
setManualError(null);
|
||||||
@@ -96,41 +96,36 @@ export const NetworkTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptedBssidKeys = acceptedItems.map(item => bssidKey(item.bssid)).join(',');
|
const acceptedBssidKeys = acceptedItems.map((item) => bssidKey(item.bssid)).join(',');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="system-section system-section--grid">
|
||||||
<div className="page-header">
|
<div className="system-block">
|
||||||
<div>
|
<div className="system-block-head">
|
||||||
<h1 className="page-title">Quản lý mạng</h1>
|
<div>
|
||||||
<p className="page-subtitle">
|
<h2 className="system-block-title">Gói mạng được chấp nhận</h2>
|
||||||
Cấu hình điểm phát WiFi được phép (SSID + BSSID/MAC) áp dụng <strong>toàn hệ thống</strong>.
|
<p className="system-block-desc">
|
||||||
Sinh viên không thể giả mạo bằng hotspot trùng tên.
|
SSID + BSSID (MAC điểm phát) áp dụng toàn hệ thống. Hotspot giả trùng tên nhưng MAC khác sẽ bị chặn.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="system-stat-pill">{acceptedItems.length} điểm phát</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="network-grid">
|
<form
|
||||||
<div className="card config-card-flat">
|
className="network-manual-form"
|
||||||
<div className="card-header-title">WiFi được chấp nhận</div>
|
onSubmit={(e) => {
|
||||||
<p className="config-card-desc">
|
e.preventDefault();
|
||||||
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Để trống = chưa bật kiểm tra WiFi.
|
handleManualAdd();
|
||||||
</p>
|
}}
|
||||||
|
>
|
||||||
<form
|
<p className="network-manual-hint">Thêm thủ công (router, lệnh netsh...)</p>
|
||||||
className="network-manual-form"
|
<div className="system-inline-form system-inline-form--stack">
|
||||||
onSubmit={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleManualAdd();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="network-manual-hint">Thêm thủ công khi cần (vd: lấy BSSID từ router hoặc lệnh netsh)</p>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="app-pool-search"
|
className="app-pool-search"
|
||||||
placeholder="SSID (tên WiFi)"
|
placeholder="SSID (tên WiFi)"
|
||||||
value={manualSsid}
|
value={manualSsid}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
setManualSsid(e.target.value);
|
setManualSsid(e.target.value);
|
||||||
setManualError(null);
|
setManualError(null);
|
||||||
}}
|
}}
|
||||||
@@ -141,7 +136,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
className="app-pool-search"
|
className="app-pool-search"
|
||||||
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
|
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
|
||||||
value={manualBssid}
|
value={manualBssid}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
setManualBssid(e.target.value);
|
setManualBssid(e.target.value);
|
||||||
setManualError(null);
|
setManualError(null);
|
||||||
}}
|
}}
|
||||||
@@ -151,17 +146,19 @@ export const NetworkTab: React.FC = () => {
|
|||||||
<button type="submit" className="btn btn-secondary" disabled={loading}>
|
<button type="submit" className="btn btn-secondary" disabled={loading}>
|
||||||
+ Thêm thủ công
|
+ Thêm thủ công
|
||||||
</button>
|
</button>
|
||||||
{manualError && <p className="network-manual-error">{manualError}</p>}
|
</div>
|
||||||
</form>
|
{manualError && <p className="network-manual-error">{manualError}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="app-pool-status">Đang tải...</div>
|
<div className="system-empty">Đang tải...</div>
|
||||||
) : acceptedItems.length === 0 ? (
|
) : acceptedItems.length === 0 ? (
|
||||||
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
|
<div className="system-empty system-empty--muted">
|
||||||
Chưa có điểm phát nào. Mở kho WiFi để thêm từ máy sinh viên đang kết nối đúng mạng trường.
|
Chưa có điểm phát. Mở kho WiFi để thêm từ máy sinh viên đang kết nối đúng mạng trường.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
|
<div className="system-table-wrap">
|
||||||
|
<table className="data-table app-pool-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>SSID</th>
|
<th>SSID</th>
|
||||||
@@ -170,7 +167,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{acceptedItems.map(item => (
|
{acceptedItems.map((item) => (
|
||||||
<tr key={bssidKey(item.bssid)}>
|
<tr key={bssidKey(item.bssid)}>
|
||||||
<td><code className="app-pool-kw">{item.ssid}</code></td>
|
<td><code className="app-pool-kw">{item.ssid}</code></td>
|
||||||
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||||
@@ -179,7 +176,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary app-pool-add-btn"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={() => removeWifi(item.bssid)}
|
onClick={() => removeWifi(item.bssid)}
|
||||||
>
|
>
|
||||||
Xóa
|
Xóa
|
||||||
@@ -189,29 +186,28 @@ export const NetworkTab: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="network-actions">
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
|
||||||
Mở kho WiFi
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
|
|
||||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="card config-card-flat network-info-card">
|
<div className="network-actions">
|
||||||
<div className="card-header-title">Cách hoạt động</div>
|
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||||
<ul className="network-info-list">
|
Mở kho WiFi
|
||||||
<li>Sinh viên kết nối WiFi → app gửi <strong>SSID + BSSID</strong> (MAC điểm phát) lên kho</li>
|
</button>
|
||||||
<li>Thầy cô chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
|
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||||
<li>Hotspot giả trùng tên nhưng MAC khác → app báo lỗi và thoát</li>
|
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
|
||||||
<li>Chưa cấu hình → không chặn (để thu thập kho trước)</li>
|
</button>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="system-block system-block--info">
|
||||||
|
<h3 className="system-info-title">Cách hoạt động</h3>
|
||||||
|
<ul className="network-info-list">
|
||||||
|
<li>Sinh viên kết nối WiFi → app gửi <strong>SSID + BSSID</strong> lên kho</li>
|
||||||
|
<li>Chọn từ kho hoặc thêm thủ công SSID + BSSID</li>
|
||||||
|
<li>Chưa cấu hình → không chặn (để thu thập kho trước)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<WifiPoolModal
|
<WifiPoolModal
|
||||||
open={poolOpen}
|
open={poolOpen}
|
||||||
onClose={() => setPoolOpen(false)}
|
onClose={() => setPoolOpen(false)}
|
||||||
@@ -221,3 +217,13 @@ export const NetworkTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated Dùng SystemTab → Gói mạng */
|
||||||
|
export const NetworkTab: React.FC = () => (
|
||||||
|
<div className="tab-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<h1 className="page-title">Quản lý mạng</h1>
|
||||||
|
</header>
|
||||||
|
<NetworkSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
103
management/src/components/OrganizationSection.tsx
Normal file
103
management/src/components/OrganizationSection.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||||
|
|
||||||
|
export function OrganizationSection() {
|
||||||
|
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
||||||
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin.listEmailDomains();
|
||||||
|
setDomains(res.data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addDomain = async () => {
|
||||||
|
if (!newDomain.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiAdmin.addEmailDomain(newDomain.trim());
|
||||||
|
setNewDomain('');
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Không thêm được');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDomain = async (id: number) => {
|
||||||
|
if (!confirm('Xóa đuôi email này?')) return;
|
||||||
|
try {
|
||||||
|
await apiAdmin.deleteEmailDomain(id);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Không xóa được');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-section">
|
||||||
|
<div className="system-block">
|
||||||
|
<div className="system-block-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="system-block-title">Đuôi email được phép</h2>
|
||||||
|
<p className="system-block-desc">
|
||||||
|
Giới hạn tài khoản giáo viên / nhân sự theo tổ chức. Chưa cấu hình = chấp nhận mọi đuôi email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="system-stat-pill">{domains.length} đuôi</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="system-inline-form">
|
||||||
|
<input
|
||||||
|
placeholder="vd: rikkeiacademy.com"
|
||||||
|
value={newDomain}
|
||||||
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={addDomain} disabled={busy}>
|
||||||
|
Thêm đuôi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="system-empty">Đang tải...</div>
|
||||||
|
) : domains.length === 0 ? (
|
||||||
|
<div className="system-empty system-empty--muted">
|
||||||
|
Chưa giới hạn đuôi email — mọi địa chỉ đều có thể đăng ký.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="system-domain-list">
|
||||||
|
{domains.map((d) => (
|
||||||
|
<li key={d.id} className="system-domain-item">
|
||||||
|
<span className="system-domain-label">@{d.domain}</span>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="system-block system-block--info">
|
||||||
|
<h3 className="system-info-title">Gợi ý</h3>
|
||||||
|
<ul className="network-info-list">
|
||||||
|
<li>Thêm từng đuôi email thuộc tổ chức (vd: <code>rikkei.edu.vn</code>)</li>
|
||||||
|
<li>Sinh viên đăng nhập client không bị ảnh hưởng bởi cấu hình này</li>
|
||||||
|
<li>Chỉ áp dụng cho tài khoản quản lý trên portal này</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,102 +1,75 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
|
import { StudentStreamImage } from './StudentStreamImage';
|
||||||
|
|
||||||
interface ProctorStreamPanelsProps {
|
interface ProctorStreamPanelsProps {
|
||||||
studentId: number;
|
studentId: number;
|
||||||
layout?: 'default' | 'focus';
|
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];
|
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3];
|
||||||
|
|
||||||
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
||||||
studentId,
|
studentId,
|
||||||
layout = 'focus',
|
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 [showWebcam, setShowWebcam] = useState(true);
|
||||||
const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x
|
const [screenZoomIdx, setScreenZoomIdx] = useState(2);
|
||||||
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
|
const [webcamZoomIdx, setWebcamZoomIdx] = useState(2);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
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 screenPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const screenZoom = ZOOM_STEPS[screenZoomIdx];
|
const screenZoom = ZOOM_STEPS[screenZoomIdx];
|
||||||
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
|
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher`;
|
|
||||||
|
|
||||||
intentionalClose.current = false;
|
|
||||||
hasOpened.current = false;
|
|
||||||
hasFrames.current = false;
|
|
||||||
setStreaming(false);
|
|
||||||
setErrorMessage(null);
|
|
||||||
setScreenFrame(null);
|
|
||||||
setWebcamFrame(null);
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
const markStreaming = () => {
|
|
||||||
if (!hasFrames.current) {
|
|
||||||
hasFrames.current = true;
|
|
||||||
setStreaming(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
hasOpened.current = true;
|
|
||||||
setStreaming(true);
|
|
||||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
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 = () => {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
intentionalClose.current = true;
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
|
|
||||||
}
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}, [studentId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => {
|
const onFsChange = () => {
|
||||||
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
|
setIsFullscreen(document.fullscreenElement === screenPanelRef.current);
|
||||||
@@ -121,12 +94,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
|
|
||||||
const zoomIn = (target: 'screen' | 'webcam') => {
|
const zoomIn = (target: 'screen' | 'webcam') => {
|
||||||
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
|
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 zoomOut = (target: 'screen' | 'webcam') => {
|
||||||
const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx;
|
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') => {
|
const zoomReset = (target: 'screen' | 'webcam') => {
|
||||||
@@ -136,12 +109,19 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
|
|
||||||
const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
|
const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
|
||||||
<div className="panel-toolbar">
|
<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>
|
<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="Phóng to" onClick={() => zoomIn(target)}>
|
||||||
<button type="button" className="proctor-tool-btn" title="Về 100%" onClick={() => zoomReset(target)}>1:1</button>
|
<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 && (
|
{onFs && (
|
||||||
<button type="button" className="proctor-tool-btn proctor-tool-btn-wide" title="Toàn màn hình" onClick={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'}
|
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -151,24 +131,22 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="proctor-stream-wrap">
|
<div className="proctor-stream-wrap">
|
||||||
<div className="proctor-stream-toolbar">
|
<div className="proctor-stream-toolbar">
|
||||||
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
|
<span className="status-pill connected">
|
||||||
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
|
<span className="status-pill-dot" />
|
||||||
|
Đang phát (HTTP)
|
||||||
</span>
|
</span>
|
||||||
<div className="proctor-stream-actions">
|
<div className="proctor-stream-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
|
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'}
|
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
|
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
|
||||||
@@ -180,17 +158,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
|
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
|
||||||
<div className="proctor-zoom-viewport">
|
<div className="proctor-zoom-viewport">
|
||||||
{screenFrame ? (
|
<StudentStreamImage
|
||||||
<img
|
studentId={studentId}
|
||||||
src={screenFrame}
|
kind="screen"
|
||||||
alt="Màn hình sinh viên"
|
className="live-frame screen-img"
|
||||||
className="live-frame screen-img"
|
style={{ transform: `scale(${screenZoom})` }}
|
||||||
style={{ transform: `scale(${screenZoom})` }}
|
/>
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,17 +176,12 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="panel-body webcam-body">
|
<div className="panel-body webcam-body">
|
||||||
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
|
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
|
||||||
{webcamFrame ? (
|
<StudentStreamImage
|
||||||
<img
|
studentId={studentId}
|
||||||
src={webcamFrame}
|
kind="webcam"
|
||||||
alt="Webcam sinh viên"
|
className="live-frame webcam-img"
|
||||||
className="live-frame webcam-img"
|
style={{ transform: `scale(${webcamZoom})` }}
|
||||||
style={{ transform: `scale(${webcamZoom})` }}
|
/>
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,40 @@ export const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ classId, schedul
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickCreateLocal = () => {
|
||||||
|
const nextLocal = [...local];
|
||||||
|
const standardTimes = [
|
||||||
|
{ period: 1, start: '07:00', end: '09:00' },
|
||||||
|
{ period: 2, start: '09:10', end: '11:10' },
|
||||||
|
{ period: 3, start: '12:10', end: '14:10' },
|
||||||
|
{ period: 4, start: '14:20', end: '16:20' },
|
||||||
|
];
|
||||||
|
let added = 0;
|
||||||
|
for (let day = 0; day <= 4; day++) {
|
||||||
|
for (const slot of standardTimes) {
|
||||||
|
const exists = nextLocal.some(s => s.dayOfWeek === day && s.period === slot.period);
|
||||||
|
if (!exists) {
|
||||||
|
nextLocal.push({
|
||||||
|
dayOfWeek: day,
|
||||||
|
period: slot.period,
|
||||||
|
startTime: slot.start,
|
||||||
|
endTime: slot.end,
|
||||||
|
courseId: 0,
|
||||||
|
courseName: '',
|
||||||
|
isActive: false, // status is tắt (false)
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (added === 0) {
|
||||||
|
alert('Tất cả các ca học từ T2 đến T6 đã tồn tại.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocal(nextLocal);
|
||||||
|
alert(`Đã tạo nhanh ${added} ca học nháp (trạng thái Tắt) cho Thứ 2–6. Vui lòng chọn môn học và bấm "Lưu lịch học".`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const payload = local.filter(s => s.startTime?.trim() && s.endTime?.trim());
|
const payload = local.filter(s => s.startTime?.trim() && s.endTime?.trim());
|
||||||
try {
|
try {
|
||||||
@@ -127,7 +161,10 @@ export const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ classId, schedul
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="schedule-editor">
|
<div className="schedule-editor">
|
||||||
<div className="schedule-editor-toolbar">
|
<div className="schedule-editor-toolbar" style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleQuickCreateLocal}>
|
||||||
|
Tạo nhanh 4 ca T2–T6 (Tắt)
|
||||||
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleApplyTemplate} disabled={applying}>
|
<button type="button" className="btn btn-secondary" onClick={handleApplyTemplate} disabled={applying}>
|
||||||
{applying ? 'Đang áp dụng...' : 'Template 4 ca (T2–T6)'}
|
{applying ? 'Đang áp dụng...' : 'Template 4 ca (T2–T6)'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
355
management/src/components/SeatingTemplatesSection.tsx
Normal file
355
management/src/components/SeatingTemplatesSection.tsx
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface SeatingTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
boardPosition: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
lockedSeats: string[]; // e.g. ["0,0", "1,2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATES: SeatingTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'tpl-standard-30',
|
||||||
|
name: 'Phòng Lab Standard (30 máy)',
|
||||||
|
rows: 5,
|
||||||
|
cols: 6,
|
||||||
|
boardPosition: 'top',
|
||||||
|
lockedSeats: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tpl-aisle-large',
|
||||||
|
name: 'Phòng Máy A1 (Có lối đi giữa)',
|
||||||
|
rows: 6,
|
||||||
|
cols: 8,
|
||||||
|
boardPosition: 'top',
|
||||||
|
lockedSeats: ['0,3', '1,3', '2,3', '3,3', '4,3', '5,3'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SeatingTemplatesSection: React.FC = () => {
|
||||||
|
const [templates, setTemplates] = useState<SeatingTemplate[]>([]);
|
||||||
|
const [editing, setEditing] = useState<SeatingTemplate | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [rows, setRows] = useState(5);
|
||||||
|
const [cols, setCols] = useState(6);
|
||||||
|
const [boardPosition, setBoardPosition] = useState<'top' | 'bottom' | 'left' | 'right'>('top');
|
||||||
|
const [lockedSeats, setLockedSeats] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('sc_seating_templates');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
setTemplates(JSON.parse(stored));
|
||||||
|
} catch {
|
||||||
|
setTemplates(DEFAULT_TEMPLATES);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('sc_seating_templates', JSON.stringify(DEFAULT_TEMPLATES));
|
||||||
|
setTemplates(DEFAULT_TEMPLATES);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveTemplates = (newTemplates: SeatingTemplate[]) => {
|
||||||
|
localStorage.setItem('sc_seating_templates', JSON.stringify(newTemplates));
|
||||||
|
setTemplates(newTemplates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setCreating(true);
|
||||||
|
setEditing(null);
|
||||||
|
setName('');
|
||||||
|
setRows(5);
|
||||||
|
setCols(6);
|
||||||
|
setBoardPosition('top');
|
||||||
|
setLockedSeats([]);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (tpl: SeatingTemplate) => {
|
||||||
|
setEditing(tpl);
|
||||||
|
setCreating(false);
|
||||||
|
setName(tpl.name);
|
||||||
|
setRows(tpl.rows);
|
||||||
|
setCols(tpl.cols);
|
||||||
|
setBoardPosition(tpl.boardPosition);
|
||||||
|
setLockedSeats(tpl.lockedSeats || []);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleLock = (r: number, c: number) => {
|
||||||
|
const key = `${r},${c}`;
|
||||||
|
if (lockedSeats.includes(key)) {
|
||||||
|
setLockedSeats(lockedSeats.filter((k) => k !== key));
|
||||||
|
} else {
|
||||||
|
setLockedSeats([...lockedSeats, key]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Vui lòng nhập tên template.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up locked seats that fall outside the current rows/cols boundary
|
||||||
|
const validLockedSeats = lockedSeats.filter((key) => {
|
||||||
|
const [r, c] = key.split(',').map(Number);
|
||||||
|
return r < rows && c < cols;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTemplate: SeatingTemplate = {
|
||||||
|
id: editing ? editing.id : `tpl-${Date.now()}`,
|
||||||
|
name: name.trim(),
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
boardPosition,
|
||||||
|
lockedSeats: validLockedSeats,
|
||||||
|
};
|
||||||
|
|
||||||
|
let updatedList: SeatingTemplate[];
|
||||||
|
if (editing) {
|
||||||
|
updatedList = templates.map((t) => (t.id === editing.id ? newTemplate : t));
|
||||||
|
} else {
|
||||||
|
updatedList = [...templates, newTemplate];
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTemplates(updatedList);
|
||||||
|
handleCloseForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (tpl: SeatingTemplate) => {
|
||||||
|
if (confirm(`Bạn có chắc chắn muốn xóa template "${tpl.name}"?`)) {
|
||||||
|
const updatedList = templates.filter((t) => t.id !== tpl.id);
|
||||||
|
saveTemplates(updatedList);
|
||||||
|
if (editing?.id === tpl.id) {
|
||||||
|
handleCloseForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to render preview grid in edit/create form
|
||||||
|
const renderDesignerGrid = () => {
|
||||||
|
const gridItems = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const key = `${r},${c}`;
|
||||||
|
const isLocked = lockedSeats.includes(key);
|
||||||
|
gridItems.push(
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleToggleLock(r, c)}
|
||||||
|
className={`seating-designer-cell ${isLocked ? 'locked' : 'available'}`}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
userSelect: 'none',
|
||||||
|
backgroundColor: isLocked ? '#e5e7eb' : '#eff6ff',
|
||||||
|
color: isLocked ? '#9ca3af' : '#1e40af',
|
||||||
|
borderColor: isLocked ? '#d1d5db' : '#bfdbfe',
|
||||||
|
transition: 'var(--transition)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLocked ? '🔒 Khóa' : `H${r + 1}-C${c + 1}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', margin: '1rem 0' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{boardPosition === 'top' && (
|
||||||
|
<div className="seating-board-label" style={{ width: '60%', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700 }}>
|
||||||
|
📢 BẢNG / GIẢNG ĐƯỜNG (TOP)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', justifyContent: 'center' }}>
|
||||||
|
{boardPosition === 'left' && (
|
||||||
|
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.5rem 0.25rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700, minHeight: '100px' }}>
|
||||||
|
📢 BẢNG (LEFT)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${cols}, minmax(72px, 1fr))`,
|
||||||
|
gap: '6px',
|
||||||
|
flex: 1,
|
||||||
|
maxHeight: '320px',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gridItems}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{boardPosition === 'right' && (
|
||||||
|
<div className="seating-board-label" style={{ writingMode: 'vertical-lr', textOrientation: 'mixed', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.5rem 0.25rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700, minHeight: '100px' }}>
|
||||||
|
📢 BẢNG (RIGHT)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{boardPosition === 'bottom' && (
|
||||||
|
<div className="seating-board-label" style={{ width: '60%', textAlign: 'center', background: '#374151', color: '#fff', padding: '0.25rem 0.5rem', borderRadius: '4px', fontSize: '0.8rem', fontWeight: 700 }}>
|
||||||
|
📢 BẢNG / GIẢNG ĐƯỜNG (BOTTOM)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700 }}>Danh sách mẫu sơ đồ chỗ ngồi</h3>
|
||||||
|
<p style={{ margin: '4px 0 0 0', color: 'var(--text-secondary)', fontSize: '0.82rem' }}>
|
||||||
|
Thiết lập sẵn cấu trúc hàng, cột, bảng và các vị trí máy hỏng hoặc lối đi để áp dụng nhanh cho lớp hoặc phòng thi.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!creating && !editing && (
|
||||||
|
<button className="btn btn-primary" onClick={handleOpenCreate}>
|
||||||
|
+ Thêm sơ đồ mẫu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(creating || editing) ? (
|
||||||
|
<div className="content-card" style={{ padding: '1.5rem', display: 'grid', gridTemplateColumns: '1fr 1.5fr', gap: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', borderRight: '1px solid var(--border-color)', paddingRight: '2rem' }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>{creating ? 'Thêm sơ đồ mẫu mới' : 'Chỉnh sửa sơ đồ mẫu'}</h4>
|
||||||
|
|
||||||
|
{error && <div style={{ color: 'var(--danger)', fontSize: '0.82rem', fontWeight: 600 }}>{error}</div>}
|
||||||
|
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Tên sơ đồ</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="Ví dụ: Phòng Lab 302, Phòng A2"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Số hàng (dọc)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="search-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={rows}
|
||||||
|
onChange={(e) => setRows(Math.max(1, Math.min(10, Number(e.target.value))))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Số cột (ngang)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={15}
|
||||||
|
className="search-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={cols}
|
||||||
|
onChange={(e) => setCols(Math.max(1, Math.min(15, Number(e.target.value))))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-label" style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontSize: '0.82rem', fontWeight: 600 }}>Vị trí bảng viết</span>
|
||||||
|
<select
|
||||||
|
className="select-filter"
|
||||||
|
style={{ width: '100%', padding: '0.5rem' }}
|
||||||
|
value={boardPosition}
|
||||||
|
onChange={(e) => setBoardPosition(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="top">Phía trước (Top)</option>
|
||||||
|
<option value="bottom">Phía sau (Bottom)</option>
|
||||||
|
<option value="left">Bên trái (Left)</option>
|
||||||
|
<option value="right">Bên phải (Right)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '10px', marginTop: '1rem' }}>
|
||||||
|
<button className="btn btn-primary" style={{ flex: 1 }} onClick={handleSave}>
|
||||||
|
Lưu lại
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={handleCloseForm}>
|
||||||
|
Hủy bỏ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: '0 0 4px 0', fontSize: '0.95rem' }}>Thiết kế vị trí khóa</h4>
|
||||||
|
<p style={{ margin: '0 0 1rem 0', color: 'var(--text-secondary)', fontSize: '0.78rem' }}>
|
||||||
|
Click chuột vào các ô bên dưới để chuyển đổi trạng thái giữa <strong>Chỗ trống khả dụng</strong> và <strong>Vị trí khóa (Không thể xếp sinh viên)</strong>.
|
||||||
|
</p>
|
||||||
|
{renderDesignerGrid()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<div key={tpl.id} className="content-card" style={{ padding: '1.25rem', display: 'flex', flexDirection: 'column', gap: '0.75rem', position: 'relative' }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 700 }}>{tpl.name}</h4>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||||
|
Kích thước: <strong>{tpl.rows} hàng × {tpl.cols} cột</strong> ({tpl.rows * tpl.cols} vị trí)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Bảng ở: <strong>{tpl.boardPosition.toUpperCase()}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Vị trí khóa: <strong>{tpl.lockedSeats ? tpl.lockedSeats.length : 0} ô</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: 'auto' }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" style={{ flex: 1 }} onClick={() => handleOpenEdit(tpl)}>
|
||||||
|
Chỉnh sửa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm text-danger"
|
||||||
|
style={{ flex: 1, borderColor: '#fca5a5', color: 'var(--danger)' }}
|
||||||
|
onClick={() => handleDelete(tpl)}
|
||||||
|
>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
management/src/components/StudentAffairsTab.tsx
Normal file
117
management/src/components/StudentAffairsTab.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
export const StudentAffairsTab: React.FC = () => {
|
||||||
|
const [showUnderDevModal, setShowUnderDevModal] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-page">
|
||||||
|
<div className="tab-page-toolbar">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="page-title">
|
||||||
|
<h1>Công Tác Sinh Viên</h1>
|
||||||
|
<p>Quản lý các hoạt động hỗ trợ, tư vấn và chăm sóc sinh viên tại Rikkei Education</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tab-page-body tab-page-scroll">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1.5rem', marginTop: '0.5rem' }}>
|
||||||
|
<div
|
||||||
|
className="content-card"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'var(--transition)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: '1.75rem',
|
||||||
|
}}
|
||||||
|
onClick={() => setShowUnderDevModal(true)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = 'var(--shadow-lg)';
|
||||||
|
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.3)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'none';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border-color)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
backgroundColor: 'var(--accent-light)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
marginBottom: '1.25rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', fontWeight: 700, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>Chăm Sóc Sinh Viên</h3>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', lineHeight: '1.5' }}>
|
||||||
|
Hỗ trợ tư vấn, giải quyết các khó khăn, theo sát quá trình học tập và đời sống tinh thần của học viên.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '1.5rem', display: 'flex', alignItems: 'center', color: 'var(--accent)', fontWeight: 600, fontSize: '0.875rem' }}>
|
||||||
|
Truy cập chức năng
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: '4px' }}>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showUnderDevModal && (
|
||||||
|
<div className="modal-overlay" style={{ zIndex: 1100 }} onClick={() => setShowUnderDevModal(false)}>
|
||||||
|
<div className="modal-container" style={{ maxWidth: '400px' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-body" style={{ textAlign: 'center', padding: '2.5rem 2rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'rgba(230, 168, 23, 0.1)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
marginBottom: '1.5rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" strokeWidth="2" 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>
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: '1.3rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>Đang phát triển</h2>
|
||||||
|
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: '1.6', marginBottom: '2rem' }}>
|
||||||
|
Tính năng <strong>Chăm Sóc Sinh Viên</strong> hiện đang được phát triển và hoàn thiện. Vui lòng quay lại sau!
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setShowUnderDevModal(false)}
|
||||||
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
Đồng ý
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,13 +12,25 @@ interface StudentAvatarProps {
|
|||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
showStatusBadge?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const StudentOnlineBadge: React.FC<{
|
||||||
|
isOnline: boolean;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}> = ({ isOnline, size = 'sm' }) => (
|
||||||
|
<span className={`student-online-badge ${isOnline ? 'online' : 'offline'} student-online-badge--${size}`}>
|
||||||
|
<span className="student-online-badge-dot" aria-hidden />
|
||||||
|
{isOnline ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
export const StudentAvatar: React.FC<StudentAvatarProps> = ({
|
export const StudentAvatar: React.FC<StudentAvatarProps> = ({
|
||||||
fullName,
|
fullName,
|
||||||
avatar,
|
avatar,
|
||||||
isOnline = false,
|
isOnline = false,
|
||||||
size = 48,
|
size = 48,
|
||||||
|
showStatusBadge = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const avatarUrl = normalizeAvatarUrl(avatar);
|
const avatarUrl = normalizeAvatarUrl(avatar);
|
||||||
@@ -30,20 +42,28 @@ export const StudentAvatar: React.FC<StudentAvatarProps> = ({
|
|||||||
}, [avatarUrl]);
|
}, [avatarUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="student-avatar-wrap" style={{ width: size, height: size }}>
|
||||||
className={`student-workspace-avatar ${isOnline ? 'online' : ''} ${showImage ? 'has-image' : ''}`}
|
<div
|
||||||
style={{ width: size, height: size, fontSize: size * 0.38 }}
|
className={`student-workspace-avatar ${isOnline ? 'online' : 'offline'} ${showImage ? 'has-image' : ''}`}
|
||||||
>
|
style={{ width: size, height: size, fontSize: size * 0.38 }}
|
||||||
{showImage ? (
|
>
|
||||||
<img
|
{showImage ? (
|
||||||
src={avatarUrl}
|
<img
|
||||||
alt={fullName}
|
src={avatarUrl}
|
||||||
className="student-avatar-img"
|
alt={fullName}
|
||||||
referrerPolicy="no-referrer"
|
className="student-avatar-img"
|
||||||
onError={() => setImgFailed(true)}
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setImgFailed(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initial
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showStatusBadge && (
|
||||||
|
<span
|
||||||
|
className={`student-avatar-status-ring ${isOnline ? 'online' : 'offline'}`}
|
||||||
|
title={isOnline ? 'Đang online' : 'Đang offline'}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
initial
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { StudentItem, StudentSessionLogItem } from '../api';
|
import type { StudentItem, StudentSessionLogItem } from '../api';
|
||||||
import { StudentAvatar } from './StudentAvatar';
|
import { StudentAvatar, StudentOnlineBadge } from './StudentAvatar';
|
||||||
import { ProctorStreamPanels } from './ProctorStreamPanels';
|
import { ProctorStreamPanels } from './ProctorStreamPanels';
|
||||||
|
|
||||||
interface StudentDetailModalProps {
|
interface StudentDetailModalProps {
|
||||||
@@ -8,8 +8,27 @@ interface StudentDetailModalProps {
|
|||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
sessionLog?: StudentSessionLogItem | null;
|
sessionLog?: StudentSessionLogItem | null;
|
||||||
onClose: () => void;
|
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 formatDuration = (totalSeconds: number) => {
|
||||||
const hrs = Math.floor(totalSeconds / 3600);
|
const hrs = Math.floor(totalSeconds / 3600);
|
||||||
const mins = Math.floor((totalSeconds % 3600) / 60);
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||||
@@ -23,24 +42,34 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
|||||||
isOnline,
|
isOnline,
|
||||||
sessionLog,
|
sessionLog,
|
||||||
onClose,
|
onClose,
|
||||||
|
initialShowProctor = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [showProctor, setShowProctor] = useState(false);
|
const [showProctor, setShowProctor] = useState(initialShowProctor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay student-detail-overlay" onClick={onClose}>
|
<div
|
||||||
<div className={`modal-container student-detail-modal ${showProctor ? 'student-detail-modal--proctor' : ''}`} onClick={e => e.stopPropagation()}>
|
className={`modal-overlay student-detail-overlay${showProctor ? ' student-detail-overlay--proctor' : ''}`}
|
||||||
<div className="modal-header">
|
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">
|
<div className="student-detail-header">
|
||||||
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={56} />
|
<StudentAvatar fullName={student.fullName} avatar={student.avatar} isOnline={isOnline} size={showProctor ? 40 : 52} />
|
||||||
<div>
|
<div className="student-detail-title-block">
|
||||||
<h2 className="modal-title" style={{ margin: 0 }}>{student.fullName}</h2>
|
<h2 className="modal-title student-detail-name">{student.fullName}</h2>
|
||||||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
<p className="student-detail-sub">
|
||||||
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>{student.studentCode}</span>
|
<span className="student-detail-code">{student.studentCode}</span>
|
||||||
{student.email && <> · {student.email}</>}
|
{student.email && <span className="student-detail-email">{student.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="student-detail-body">
|
<div className="student-detail-body">
|
||||||
@@ -48,8 +77,8 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
|||||||
<div className="student-detail-meta">
|
<div className="student-detail-meta">
|
||||||
<div className="student-meta-item">
|
<div className="student-meta-item">
|
||||||
<span className="student-meta-label">Trạng thái</span>
|
<span className="student-meta-label">Trạng thái</span>
|
||||||
<span className={`student-meta-value ${isOnline ? 'online' : ''}`}>
|
<span className="student-meta-value">
|
||||||
{isOnline ? '● Online' : '○ Offline'}
|
<StudentOnlineBadge isOnline={isOnline} size="md" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{student.phone && (
|
{student.phone && (
|
||||||
@@ -62,20 +91,20 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="student-meta-item">
|
<div className="student-meta-item">
|
||||||
<span className="student-meta-label">Online (ca)</span>
|
<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)}
|
{formatDuration(sessionLog.onlineSeconds)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="student-meta-item">
|
<div className="student-meta-item">
|
||||||
<span className="student-meta-label">Offline (ca)</span>
|
<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)}
|
{formatDuration(sessionLog.offlineSeconds)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
|
{sessionLog.wifiSsids && sessionLog.wifiSsids !== '—' && (
|
||||||
<div className="student-meta-item">
|
<div className="student-meta-item">
|
||||||
<span className="student-meta-label">WiFi</span>
|
<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}
|
{sessionLog.wifiSsids}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,14 +115,14 @@ export const StudentDetailModal: React.FC<StudentDetailModalProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className={`btn student-detail-proctor-toggle ${showProctor ? 'btn-secondary' : 'btn-primary'}`}
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
onClick={() => setShowProctor((v) => !v)}
|
||||||
onClick={() => setShowProctor(v => !v)}
|
|
||||||
>
|
>
|
||||||
|
<IconMonitor size={14} />
|
||||||
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
|
{showProctor ? 'Ẩn giám sát' : 'Xem webcam & màn hình'}
|
||||||
</button>
|
</button>
|
||||||
{!isOnline && !showProctor && (
|
{!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.
|
Sinh viên offline — stream chỉ có khi app Simple Care đang chạy.
|
||||||
</p>
|
</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 { api } from '../api';
|
||||||
import type { StudentItem, SyncStatus } 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 = () => {
|
export const StudentsTab: React.FC = () => {
|
||||||
const [students, setStudents] = useState<StudentItem[]>([]);
|
const [students, setStudents] = useState<StudentItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -9,8 +93,6 @@ export const StudentsTab: React.FC = () => {
|
|||||||
const [pageSize] = useState(15);
|
const [pageSize] = useState(15);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Sync state
|
|
||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||||
|
|
||||||
const fetchStudents = async () => {
|
const fetchStudents = async () => {
|
||||||
@@ -49,7 +131,6 @@ export const StudentsTab: React.FC = () => {
|
|||||||
fetchSyncStatus();
|
fetchSyncStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync polling logic
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: any;
|
let timer: any;
|
||||||
if (syncStatus?.running) {
|
if (syncStatus?.running) {
|
||||||
@@ -57,7 +138,7 @@ export const StudentsTab: React.FC = () => {
|
|||||||
const isRunning = await fetchSyncStatus();
|
const isRunning = await fetchSyncStatus();
|
||||||
if (!isRunning) {
|
if (!isRunning) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
fetchStudents(); // Reload data when sync completes
|
fetchStudents();
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
@@ -69,186 +150,565 @@ export const StudentsTab: React.FC = () => {
|
|||||||
const handleStartSync = async () => {
|
const handleStartSync = async () => {
|
||||||
try {
|
try {
|
||||||
await api.startStudentsSync();
|
await api.startStudentsSync();
|
||||||
// Set local state to running to trigger useEffect poller
|
|
||||||
setSyncStatus({
|
setSyncStatus({
|
||||||
running: true,
|
running: true,
|
||||||
done: false,
|
done: false,
|
||||||
total: 0,
|
total: 0,
|
||||||
synced: 0,
|
synced: 0,
|
||||||
updatedAt: Date.now() / 1000
|
updatedAt: Date.now() / 1000,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Không thể bắt đầu đồng bộ sinh viên');
|
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 =
|
||||||
const syncPercent = syncStatus && syncStatus.total > 0
|
syncStatus && syncStatus.total > 0
|
||||||
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
? Math.round((syncStatus.synced / syncStatus.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize) || 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tab-page">
|
<div className="tab-page">
|
||||||
<div className="tab-page-toolbar">
|
<div className="tab-page-toolbar">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<h1>Danh Sách Sinh Viên</h1>
|
<h1 className="page-title-heading">
|
||||||
<p>Danh sách toàn bộ sinh viên trong hệ thống được đồng bộ</p>
|
<span className="page-title-icon" aria-hidden>
|
||||||
</div>
|
<IconUsers />
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleStartSync}
|
|
||||||
disabled={syncStatus?.running}
|
|
||||||
>
|
|
||||||
{syncStatus?.running ? (
|
|
||||||
<>
|
|
||||||
<div className="sync-spinner"></div> Đồng bộ...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'🔄 Đồ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-header">
|
|
||||||
<div className="sync-title">
|
|
||||||
{syncStatus.running && <div className="sync-spinner"></div>}
|
|
||||||
<span>
|
|
||||||
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
|
|
||||||
{syncStatus.done && '🎉 Đồng bộ toàn bộ sinh viên hoàn tất!'}
|
|
||||||
{syncStatus.error && `❌ Đồng bộ thất bại: ${syncStatus.error}`}
|
|
||||||
</span>
|
</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: '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" style={{ width: 18, height: 18 }} />
|
||||||
|
Đồng bộ... {syncPercent}%
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconRefresh />
|
||||||
|
Đồng bộ toàn bộ SV
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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" />}
|
||||||
|
<span>
|
||||||
|
{syncStatus.running && `Đang tải sinh viên... Trang ${syncStatus.page || 0} (${syncPercent}%)`}
|
||||||
|
{syncStatus.done && (
|
||||||
|
<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: 6, color: 'var(--danger)' }}>
|
||||||
|
<IconAlert />
|
||||||
|
{syncStatus.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
{syncStatus.running && (
|
||||||
Đã tải: {syncStatus.synced} / {syncStatus.total} sinh viên
|
<div className="sync-bar-container">
|
||||||
|
<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(--accent-hover)' }}>
|
||||||
|
Hệ thống đang kéo dữ liệu trang {syncStatus.page}...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="control-bar">
|
||||||
|
<div className="search-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
placeholder="Tìm theo tên, mã SV, email, số điện thoại..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="search-icon">
|
||||||
|
<IconSearch />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tab-page-body">
|
||||||
|
<div className="table-wrapper table-fill">
|
||||||
|
{loading ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<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)' }}>
|
||||||
|
<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 "Đồng bộ toàn bộ SV" để tải dữ liệu.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-scroll-container">
|
||||||
|
<table className="data-table student-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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 => {
|
||||||
|
const isActive = st.status === 'Đang học' || st.status === 'active';
|
||||||
|
return (
|
||||||
|
<tr key={st.id}>
|
||||||
|
<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>
|
||||||
|
<div className="student-id">ID: {st.rkId}</div>
|
||||||
|
</td>
|
||||||
|
<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 className="meta-row meta-row--secondary">
|
||||||
|
<IconPhone />
|
||||||
|
<span className="meta-text">{st.phone}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<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 className="col-system" data-label="Phân hệ">
|
||||||
|
<span className="badge badge-system">
|
||||||
|
{st.systemName || 'Chung'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<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 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>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Control filters */}
|
|
||||||
<div className="control-bar">
|
|
||||||
<div className="search-input-wrapper" style={{ maxWidth: '400px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
placeholder="Tìm theo tên, mã SV, email, số điện thoại..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="search-icon">🔍</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
) : students.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">👥</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>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="data-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>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{students.map(st => (
|
|
||||||
<tr key={st.id}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{st.studentCode}</td>
|
|
||||||
<td style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
|
|
||||||
{st.fullName}
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>ID: {st.rkId}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>📧 {st.email}</div>
|
|
||||||
{st.phone && <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>📞 {st.phone}</div>}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>🎂 {st.dateOfBirth ? new Date(st.dateOfBirth).toLocaleDateString('vi-VN') : '—'}</div>
|
|
||||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
||||||
Giới tính: {st.gender === 1 ? 'Nam' : st.gender === 0 ? 'Nữ' : 'Khác'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-muted">
|
|
||||||
{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'}`}>
|
|
||||||
{st.status || 'Đang học'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination controls */}
|
|
||||||
{!loading && students.length > 0 && (
|
{!loading && students.length > 0 && (
|
||||||
<div className="tab-page-footer">
|
<div className="tab-page-footer">
|
||||||
<div className="pagination-row">
|
<div className="pagination-row">
|
||||||
<div>
|
<div className="pagination-info">
|
||||||
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
|
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}
|
||||||
|
aria-label="Trang trước"
|
||||||
|
>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</button>
|
||||||
|
<span className="pagination-current">
|
||||||
|
Trang {page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="pagination-btn"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
aria-label="Trang sau"
|
||||||
|
>
|
||||||
|
<IconChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pagination-btn-group">
|
|
||||||
<button
|
|
||||||
className="pagination-btn"
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
◀
|
|
||||||
</button>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', padding: '0 1rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
|
||||||
Trang {page} / {Math.ceil(total / pageSize) || 1}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="pagination-btn"
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
disabled={page >= Math.ceil(total / pageSize)}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
107
management/src/components/SystemTab.tsx
Normal file
107
management/src/components/SystemTab.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
navigateSystem,
|
||||||
|
parseRoute,
|
||||||
|
SYSTEM_SECTION_LABELS,
|
||||||
|
type SystemSection,
|
||||||
|
} from '../navigation';
|
||||||
|
import { OrganizationSection } from './OrganizationSection';
|
||||||
|
import { NetworkSection } from './NetworkSection';
|
||||||
|
import { AppTemplatesSection } from './AppTemplatesSection';
|
||||||
|
import { SeatingTemplatesSection } from './SeatingTemplatesSection';
|
||||||
|
|
||||||
|
const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'organization',
|
||||||
|
hint: 'Đuôi email & tổ chức',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'network',
|
||||||
|
hint: 'WiFi được phép',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||||
|
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||||
|
<circle cx="12" cy="20" r="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'templates',
|
||||||
|
hint: 'Bộ keyword app',
|
||||||
|
icon: (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seating',
|
||||||
|
hint: 'Sơ đồ chỗ ngồi',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
|
<line x1="15" y1="3" x2="15" y2="21" />
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
|
<line x1="3" y1="15" x2="21" y2="15" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SystemTab: React.FC = () => {
|
||||||
|
const [section, setSection] = useState<SystemSection>(() => parseRoute().systemSection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setSection(parseRoute().systemSection);
|
||||||
|
window.addEventListener('popstate', sync);
|
||||||
|
return () => window.removeEventListener('popstate', sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectSection = (id: SystemSection) => {
|
||||||
|
setSection(id);
|
||||||
|
navigateSystem(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-page system-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<h1 className="page-title">Quản lý hệ thống</h1>
|
||||||
|
<p className="page-desc">
|
||||||
|
Cấu hình tổ chức, mạng WiFi và khung ứng dụng dùng chung cho toàn hệ thống.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="system-subnav" aria-label="Mục hệ thống">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className={`system-subnav-btn ${section === s.id ? 'active' : ''}`}
|
||||||
|
onClick={() => selectSection(s.id)}
|
||||||
|
>
|
||||||
|
<span className="system-subnav-icon">{s.icon}</span>
|
||||||
|
<span className="system-subnav-text">
|
||||||
|
<strong>{SYSTEM_SECTION_LABELS[s.id]}</strong>
|
||||||
|
<small>{s.hint}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="system-panel">
|
||||||
|
{section === 'organization' && <OrganizationSection />}
|
||||||
|
{section === 'network' && <NetworkSection />}
|
||||||
|
{section === 'templates' && <AppTemplatesSection />}
|
||||||
|
{section === 'seating' && <SeatingTemplatesSection />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
255
management/src/components/ViolationsPanel.tsx
Normal file
255
management/src/components/ViolationsPanel.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
apiFetchClassViolations,
|
||||||
|
apiFetchExamViolations,
|
||||||
|
VIOLATION_KIND_OPTIONS,
|
||||||
|
type StudentViolationItem,
|
||||||
|
} from '../api';
|
||||||
|
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 }
|
||||||
|
| { mode: 'exam'; examId: number };
|
||||||
|
|
||||||
|
function todayLocal(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso?: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString('vi-VN');
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const res =
|
||||||
|
props.mode === 'class'
|
||||||
|
? await apiFetchClassViolations(props.classId, date, kind)
|
||||||
|
: await apiFetchExamViolations(props.examId, date, kind);
|
||||||
|
setRows(res.data || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e?.message || 'Không tải được danh sách vi phạm');
|
||||||
|
setRows([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [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();
|
||||||
|
});
|
||||||
|
}, [props, date, load]);
|
||||||
|
|
||||||
|
const isExam = props.mode === 'exam';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="vp-input search-input"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<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="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 className="vp-count">{rows.length} vi phạm</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Error message ── */}
|
||||||
|
{err && <div className="vp-error form-error">{err}</div>}
|
||||||
|
|
||||||
|
{/* ── Table ── */}
|
||||||
|
<div className="vp-table-scroll attendance-table-scroll table-wrapper">
|
||||||
|
{loading && rows.length === 0 ? (
|
||||||
|
<div className="vp-empty">
|
||||||
|
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="vp-empty">
|
||||||
|
<IconShieldOff size={36} />
|
||||||
|
<span>Không có vi phạm trong ngày đã chọn.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="vp-table data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Thời gian</th>
|
||||||
|
<th>Sinh viên</th>
|
||||||
|
<th>Mã SV</th>
|
||||||
|
<th>Loại</th>
|
||||||
|
<th>Chi tiết</th>
|
||||||
|
<th>Ngữ cảnh</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 style={{ fontSize: '0.78rem' }}>{r.studentCode || r.studentRkId}</code></td>
|
||||||
|
<td>
|
||||||
|
<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: 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1497
management/src/components/WorkspaceSeatingChart.tsx
Normal file
1497
management/src/components/WorkspaceSeatingChart.tsx
Normal file
File diff suppressed because it is too large
Load Diff
91
management/src/hooks/useGitHubConnection.ts
Normal file
91
management/src/hooks/useGitHubConnection.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiGitHub } from '../api';
|
||||||
|
|
||||||
|
export function useGitHubConnection() {
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [githubLogin, setGithubLogin] = useState('');
|
||||||
|
const [canDeleteRepos, setCanDeleteRepos] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const st = await apiGitHub.status();
|
||||||
|
setConnected(!!st.connected);
|
||||||
|
setGithubLogin(st.githubLogin || '');
|
||||||
|
setCanDeleteRepos(!!st.canDeleteRepos);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setConnected(false);
|
||||||
|
setGithubLogin('');
|
||||||
|
setCanDeleteRepos(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().catch(console.error);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (e: MessageEvent) => {
|
||||||
|
const data = e.data as { type?: string; ok?: boolean; detail?: string };
|
||||||
|
if (data?.type !== 'simple-care-github-connected') return;
|
||||||
|
setConnecting(false);
|
||||||
|
if (data.ok) {
|
||||||
|
refresh().catch(console.error);
|
||||||
|
setMessage(typeof data.detail === 'string' ? `Đã kết nối GitHub @${data.detail}` : 'Đã kết nối GitHub');
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
setError(typeof data.detail === 'string' ? data.detail : 'Kết nối GitHub thất bại');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
return () => window.removeEventListener('message', onMessage);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
setConnecting(true);
|
||||||
|
try {
|
||||||
|
const { authorizeUrl } = await apiGitHub.authorizeUrl();
|
||||||
|
const w = window.open(authorizeUrl, 'simple_care_github_oauth', 'width=720,height=760');
|
||||||
|
if (!w) {
|
||||||
|
setConnecting(false);
|
||||||
|
setError('Trình duyệt chặn popup — cho phép popup rồi thử lại.');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setConnecting(false);
|
||||||
|
setError(e?.message || 'Không mở được OAuth GitHub');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = async () => {
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await apiGitHub.disconnect();
|
||||||
|
setConnected(false);
|
||||||
|
setGithubLogin('');
|
||||||
|
setCanDeleteRepos(false);
|
||||||
|
setMessage('Đã ngắt kết nối GitHub');
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Ngắt kết nối thất bại');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
githubLogin,
|
||||||
|
canDeleteRepos,
|
||||||
|
connecting,
|
||||||
|
error,
|
||||||
|
message,
|
||||||
|
setError,
|
||||||
|
setMessage,
|
||||||
|
refresh,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,60 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { type ChatMessage } from '../api';
|
import { type ChatMessage, getWsUrl } from '../api';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { playChatSound } from '../utils/notifySound';
|
import { playChatSound, playAlertSound } from '../utils/notifySound';
|
||||||
|
|
||||||
type ChatIncomingHandler = (msg: ChatMessage) => void;
|
type ChatIncomingHandler = (msg: ChatMessage) => void;
|
||||||
|
export type PresenceUpdate = { studentId: number; online: boolean; classId?: number };
|
||||||
|
export type StudentViolationEvent = {
|
||||||
|
studentId: number;
|
||||||
|
studentName?: string;
|
||||||
|
studentCode?: string;
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
monitorMode?: string;
|
||||||
|
classId?: number;
|
||||||
|
examRoomId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const handlers = new Set<ChatIncomingHandler>();
|
const chatHandlers = new Set<ChatIncomingHandler>();
|
||||||
|
const presenceHandlers = new Set<(p: PresenceUpdate) => void>();
|
||||||
|
const violationHandlers = new Set<(v: StudentViolationEvent) => void>();
|
||||||
|
|
||||||
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
|
export function onStaffChatMessage(handler: ChatIncomingHandler): () => void {
|
||||||
handlers.add(handler);
|
chatHandlers.add(handler);
|
||||||
return () => { handlers.delete(handler); };
|
return () => { chatHandlers.delete(handler); };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */
|
export function onStudentPresence(handler: (p: PresenceUpdate) => void): () => void {
|
||||||
|
presenceHandlers.add(handler);
|
||||||
|
return () => { presenceHandlers.delete(handler); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onStudentViolation(handler: (v: StudentViolationEvent) => void): () => void {
|
||||||
|
violationHandlers.add(handler);
|
||||||
|
return () => { violationHandlers.delete(handler); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBackoff(attempt: number): number {
|
||||||
|
const base = Math.min(1000 * 2 ** Math.min(attempt, 4), 10000);
|
||||||
|
return base + Math.floor(Math.random() * 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kindLabel(kind: string): string {
|
||||||
|
switch (kind) {
|
||||||
|
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';
|
||||||
|
case 'session_change': return 'Khóa / đổi phiên';
|
||||||
|
case 'virtual_desktop': return 'Desktop ảo';
|
||||||
|
case 'wifi': return 'WiFi trái phép';
|
||||||
|
case 'guard': return 'Vi phạm môi trường';
|
||||||
|
default: return kind || 'Vi phạm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebSocket luôn bật khi đã login — chat + presence + violation + keepalive. */
|
||||||
export function StaffChatSocket() {
|
export function StaffChatSocket() {
|
||||||
const { staff, token } = useAuth();
|
const { staff, token } = useAuth();
|
||||||
const staffIdRef = useRef(0);
|
const staffIdRef = useRef(0);
|
||||||
@@ -21,37 +63,85 @@ export function StaffChatSocket() {
|
|||||||
staffIdRef.current = staff?.id ?? 0;
|
staffIdRef.current = staff?.id ?? 0;
|
||||||
}, [staff?.id]);
|
}, [staff?.id]);
|
||||||
|
|
||||||
const dispatch = useCallback((msg: ChatMessage) => {
|
const dispatchChat = useCallback((msg: ChatMessage) => {
|
||||||
handlers.forEach((h) => h(msg));
|
chatHandlers.forEach((h) => h(msg));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !staff?.id) return;
|
if (!token || !staff?.id) return;
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`);
|
||||||
const wsUrl = `${protocol}//${window.location.hostname}:8080/ws?role=teacher&staffId=${staff.id}`;
|
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
const clearPing = () => {
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
ws = new WebSocket(wsUrl);
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
attempt = 0;
|
||||||
|
clearPing();
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
if (ws?.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ event: 'client:ping', data: {} }));
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(ev.data);
|
const payload = JSON.parse(ev.data);
|
||||||
|
if (payload.event === 'client:pong') return;
|
||||||
|
if (payload.event === 'presence:update') {
|
||||||
|
const studentId = Number(payload.data?.studentId ?? 0);
|
||||||
|
if (!studentId) return;
|
||||||
|
presenceHandlers.forEach((h) => h({
|
||||||
|
studentId,
|
||||||
|
online: !!payload.data?.online,
|
||||||
|
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.event === 'teacher:student-violation') {
|
||||||
|
const studentId = Number(payload.data?.studentId ?? 0);
|
||||||
|
if (!studentId) return;
|
||||||
|
playAlertSound();
|
||||||
|
violationHandlers.forEach((h) => h({
|
||||||
|
studentId,
|
||||||
|
studentName: payload.data?.studentName || '',
|
||||||
|
studentCode: payload.data?.studentCode || '',
|
||||||
|
kind: String(payload.data?.kind || ''),
|
||||||
|
reason: String(payload.data?.reason || ''),
|
||||||
|
monitorMode: payload.data?.monitorMode || '',
|
||||||
|
classId: Number(payload.data?.classId ?? 0) || undefined,
|
||||||
|
examRoomId: Number(payload.data?.examRoomId ?? 0) || undefined,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (payload.event !== 'chat:message') return;
|
if (payload.event !== 'chat:message') return;
|
||||||
const msg = payload.data as ChatMessage;
|
const msg = payload.data as ChatMessage;
|
||||||
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
|
const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0);
|
||||||
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
|
if (targetStaff > 0 && targetStaff !== staffIdRef.current) return;
|
||||||
dispatch(msg);
|
dispatchChat(msg);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
|
clearPing();
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
retryTimer = setTimeout(connect, 3000);
|
retryTimer = setTimeout(connect, scheduleBackoff(attempt++));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -60,12 +150,13 @@ export function StaffChatSocket() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
clearPing();
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
ws?.close();
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [token, staff?.id, dispatch]);
|
}, [token, staff?.id, dispatchChat]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { playChatSound };
|
export { playChatSound, playAlertSound };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'network' | 'email-domains' | 'profile';
|
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
|
||||||
|
|
||||||
|
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
|
||||||
|
|
||||||
export interface NavEntry {
|
export interface NavEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6,33 +8,77 @@ export interface NavEntry {
|
|||||||
tab: TabId;
|
tab: TabId;
|
||||||
classId?: number;
|
classId?: number;
|
||||||
examId?: number;
|
examId?: number;
|
||||||
|
systemSection?: SystemSection;
|
||||||
label: string;
|
label: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SYSTEM_SECTION_LABELS: Record<SystemSection, string> = {
|
||||||
|
organization: 'Tổ chức',
|
||||||
|
network: 'Gói mạng',
|
||||||
|
templates: 'Khung ứng dụng',
|
||||||
|
seating: 'Sơ đồ chỗ ngồi',
|
||||||
|
};
|
||||||
|
|
||||||
export const TAB_LABELS: Record<TabId, string> = {
|
export const TAB_LABELS: Record<TabId, string> = {
|
||||||
dashboard: 'Tổng quan',
|
dashboard: 'Tổng quan',
|
||||||
classes: 'Lớp học',
|
classes: 'Lớp học',
|
||||||
students: 'Sinh viên',
|
students: 'Sinh viên',
|
||||||
learning: 'Giám sát & Lịch học',
|
learning: 'Giám sát & Lịch học',
|
||||||
exams: 'Phòng thi',
|
exams: 'Phòng thi',
|
||||||
network: 'Quản lý mạng',
|
system: 'Hệ thống',
|
||||||
'email-domains': 'Đuôi email',
|
|
||||||
profile: 'Tài khoản của tôi',
|
profile: 'Tài khoản của tôi',
|
||||||
|
'student-affairs': 'Công Tác Sinh Viên',
|
||||||
|
applications: 'Ứng Dụng',
|
||||||
};
|
};
|
||||||
|
|
||||||
const HISTORY_KEY = 'sc_nav_history';
|
const HISTORY_KEY = 'sc_nav_history';
|
||||||
const MAX_HISTORY = 10;
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
function isTabId(value: string | null): value is TabId {
|
function isTabId(value: string | null): value is TabId {
|
||||||
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'exams' || value === 'network' || value === 'email-domains' || value === 'profile';
|
return (
|
||||||
|
value === 'dashboard' ||
|
||||||
|
value === 'classes' ||
|
||||||
|
value === 'students' ||
|
||||||
|
value === 'learning' ||
|
||||||
|
value === 'exams' ||
|
||||||
|
value === 'system' ||
|
||||||
|
value === 'profile' ||
|
||||||
|
value === 'student-affairs' ||
|
||||||
|
value === 'applications'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRoute(): { tab: TabId; classId: number | null; examId: number | null } {
|
function isSystemSection(value: string | null): value is SystemSection {
|
||||||
|
return value === 'organization' || value === 'network' || value === 'templates' || value === 'seating';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTabAndSection(tabParam: string | null): { tab: TabId; systemSection: SystemSection } {
|
||||||
|
if (tabParam === 'email-domains' || tabParam === 'accounts') {
|
||||||
|
return { tab: 'system', systemSection: 'organization' };
|
||||||
|
}
|
||||||
|
if (tabParam === 'network') {
|
||||||
|
return { tab: 'system', systemSection: 'network' };
|
||||||
|
}
|
||||||
|
const tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const secParam = params.get('section');
|
||||||
|
let systemSection: SystemSection = 'organization';
|
||||||
|
if (isSystemSection(secParam)) {
|
||||||
|
systemSection = secParam;
|
||||||
|
}
|
||||||
|
return { tab, systemSection };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoute(): {
|
||||||
|
tab: TabId;
|
||||||
|
classId: number | null;
|
||||||
|
examId: number | null;
|
||||||
|
systemSection: SystemSection;
|
||||||
|
} {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const tabParam = params.get('tab');
|
const tabParam = params.get('tab');
|
||||||
let tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
|
const { tab, systemSection } = resolveTabAndSection(tabParam);
|
||||||
if (tabParam === 'accounts') tab = 'email-domains';
|
|
||||||
const classIdRaw = params.get('classId');
|
const classIdRaw = params.get('classId');
|
||||||
const classId = classIdRaw ? Number(classIdRaw) : null;
|
const classId = classIdRaw ? Number(classIdRaw) : null;
|
||||||
const examIdRaw = params.get('examId');
|
const examIdRaw = params.get('examId');
|
||||||
@@ -41,12 +87,21 @@ export function parseRoute(): { tab: TabId; classId: number | null; examId: numb
|
|||||||
tab,
|
tab,
|
||||||
classId: classId && !Number.isNaN(classId) ? classId : null,
|
classId: classId && !Number.isNaN(classId) ? classId : null,
|
||||||
examId: examId && !Number.isNaN(examId) ? examId : null,
|
examId: examId && !Number.isNaN(examId) ? examId : null,
|
||||||
|
systemSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUrl(tab: TabId, classId?: number | null, examId?: number | null): string {
|
export function buildUrl(
|
||||||
|
tab: TabId,
|
||||||
|
classId?: number | null,
|
||||||
|
examId?: number | null,
|
||||||
|
systemSection?: SystemSection,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('tab', tab);
|
params.set('tab', tab);
|
||||||
|
if (tab === 'system' && systemSection) {
|
||||||
|
params.set('section', systemSection);
|
||||||
|
}
|
||||||
if (classId) {
|
if (classId) {
|
||||||
params.set('classId', String(classId));
|
params.set('classId', String(classId));
|
||||||
}
|
}
|
||||||
@@ -71,15 +126,16 @@ function writeHistory(entries: NavEntry[]) {
|
|||||||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
|
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number) {
|
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number, section?: SystemSection) {
|
||||||
if (kind === 'class') return `class:${id}`;
|
if (kind === 'class') return `class:${id}`;
|
||||||
if (kind === 'exam') return `exam:${id}`;
|
if (kind === 'exam') return `exam:${id}`;
|
||||||
|
if (tab === 'system' && section) return `tab:system:${section}`;
|
||||||
return `tab:${tab}`;
|
return `tab:${tab}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
||||||
const history = readHistory();
|
const history = readHistory();
|
||||||
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId);
|
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId, entry.systemSection);
|
||||||
const next: NavEntry = {
|
const next: NavEntry = {
|
||||||
...entry,
|
...entry,
|
||||||
id,
|
id,
|
||||||
@@ -89,21 +145,47 @@ export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
|||||||
writeHistory([next, ...filtered]);
|
writeHistory([next, ...filtered]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(tab: TabId, id?: number | null, label?: string, kind: 'class' | 'exam' = 'class') {
|
export function navigate(
|
||||||
const url = kind === 'exam'
|
tab: TabId,
|
||||||
? buildUrl(tab, null, id)
|
id?: number | null,
|
||||||
: buildUrl(tab, id);
|
label?: string,
|
||||||
|
kind: 'class' | 'exam' = 'class',
|
||||||
|
systemSection?: SystemSection,
|
||||||
|
) {
|
||||||
|
const url =
|
||||||
|
kind === 'exam'
|
||||||
|
? buildUrl(tab, null, id, systemSection)
|
||||||
|
: buildUrl(tab, id, undefined, systemSection);
|
||||||
window.history.pushState({}, '', url);
|
window.history.pushState({}, '', url);
|
||||||
|
|
||||||
if (id && label) {
|
if (id && label) {
|
||||||
pushNav({ kind, tab, classId: kind === 'class' ? id : undefined, examId: kind === 'exam' ? id : undefined, label });
|
pushNav({
|
||||||
|
kind,
|
||||||
|
tab,
|
||||||
|
classId: kind === 'class' ? id : undefined,
|
||||||
|
examId: kind === 'exam' ? id : undefined,
|
||||||
|
label,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] });
|
const navLabel =
|
||||||
|
tab === 'system' && systemSection
|
||||||
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[systemSection]}`
|
||||||
|
: TAB_LABELS[tab];
|
||||||
|
pushNav({
|
||||||
|
kind: 'tab',
|
||||||
|
tab,
|
||||||
|
systemSection: tab === 'system' ? systemSection : undefined,
|
||||||
|
label: navLabel,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function navigateSystem(section: SystemSection) {
|
||||||
|
navigate('system', null, undefined, 'class', section);
|
||||||
|
}
|
||||||
|
|
||||||
export function goBack(fallbackTab: TabId = 'classes') {
|
export function goBack(fallbackTab: TabId = 'classes') {
|
||||||
const history = readHistory();
|
const history = readHistory();
|
||||||
const current = parseRoute();
|
const current = parseRoute();
|
||||||
@@ -111,18 +193,19 @@ export function goBack(fallbackTab: TabId = 'classes') {
|
|||||||
? entryKey('class', current.tab, current.classId)
|
? entryKey('class', current.tab, current.classId)
|
||||||
: current.examId
|
: current.examId
|
||||||
? entryKey('exam', current.tab, current.examId)
|
? entryKey('exam', current.tab, current.examId)
|
||||||
: entryKey('tab', current.tab);
|
: entryKey('tab', current.tab, undefined, current.systemSection);
|
||||||
|
|
||||||
const remaining = history.filter((h) => h.id !== currentId);
|
const remaining = history.filter((h) => h.id !== currentId);
|
||||||
writeHistory(remaining);
|
writeHistory(remaining);
|
||||||
|
|
||||||
const previous = remaining[0];
|
const previous = remaining[0];
|
||||||
if (previous) {
|
if (previous) {
|
||||||
const url = previous.kind === 'class' && previous.classId
|
const url =
|
||||||
? buildUrl(previous.tab, previous.classId)
|
previous.kind === 'class' && previous.classId
|
||||||
: previous.kind === 'exam' && previous.examId
|
? buildUrl(previous.tab, previous.classId)
|
||||||
? buildUrl(previous.tab, null, previous.examId)
|
: previous.kind === 'exam' && previous.examId
|
||||||
: buildUrl(previous.tab);
|
? buildUrl(previous.tab, null, previous.examId)
|
||||||
|
: buildUrl(previous.tab, null, null, previous.systemSection);
|
||||||
window.history.pushState({}, '', url);
|
window.history.pushState({}, '', url);
|
||||||
} else {
|
} else {
|
||||||
window.history.pushState({}, '', buildUrl(fallbackTab));
|
window.history.pushState({}, '', buildUrl(fallbackTab));
|
||||||
|
|||||||
15
management/src/pdfjs.d.ts
vendored
Normal file
15
management/src/pdfjs.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
declare module 'pdfjs-dist/build/pdf' {
|
||||||
|
export const GlobalWorkerOptions: { workerSrc: string };
|
||||||
|
export function getDocument(src: { data: Uint8Array }): { promise: Promise<{
|
||||||
|
numPages: number;
|
||||||
|
getPage: (n: number) => Promise<{
|
||||||
|
getViewport: (opts: { scale: number }) => { width: number; height: number };
|
||||||
|
render: (opts: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => { promise: Promise<void> };
|
||||||
|
}>;
|
||||||
|
}> };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'pdfjs-dist/build/pdf.worker.min.js?url' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
21
management/src/utils/appKeywords.ts
Normal file
21
management/src/utils/appKeywords.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function parseKeywordSet(csv: string): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
csv
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeKeywordCSV(current: string, addition: string): string {
|
||||||
|
const set = parseKeywordSet(current);
|
||||||
|
addition.split(',').forEach((k) => {
|
||||||
|
const kw = k.trim().toLowerCase();
|
||||||
|
if (kw) set.add(kw);
|
||||||
|
});
|
||||||
|
return Array.from(set).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countKeywords(csv: string): number {
|
||||||
|
return parseKeywordSet(csv).size;
|
||||||
|
}
|
||||||
@@ -35,3 +35,29 @@ export function playChatSound() {
|
|||||||
playTone(880, t, 0.12);
|
playTone(880, t, 0.12);
|
||||||
playTone(1174, t + 0.14, 0.14);
|
playTone(1174, t + 0.14, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Louder alert for student violations (quit / kill app) */
|
||||||
|
export function playAlertSound() {
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
if (!ctx) return;
|
||||||
|
if (ctx.state === 'suspended') {
|
||||||
|
void ctx.resume();
|
||||||
|
}
|
||||||
|
const playTone = (freq: number, start: number, duration: number) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
gain.gain.setValueAtTime(0.0001, start);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + duration + 0.02);
|
||||||
|
};
|
||||||
|
const t = ctx.currentTime;
|
||||||
|
playTone(520, t, 0.16);
|
||||||
|
playTone(390, t + 0.18, 0.2);
|
||||||
|
playTone(520, t + 0.4, 0.18);
|
||||||
|
}
|
||||||
|
|||||||
33
management/src/utils/renderExamPdf.ts
Normal file
33
management/src/utils/renderExamPdf.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
|
||||||
|
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||||
|
|
||||||
|
export async function renderExamPdfPages(container: HTMLElement, bytes: Uint8Array) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
|
||||||
|
const pad = 16;
|
||||||
|
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
|
||||||
|
const maxWidth = Math.max(360, width - pad * 2);
|
||||||
|
|
||||||
|
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
|
||||||
|
const page = await pdf.getPage(pageNum);
|
||||||
|
const base = page.getViewport({ scale: 1 });
|
||||||
|
const scale = Math.min(1.6, maxWidth / base.width);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'exam-pdf-page-wrap';
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.className = 'exam-pdf-page';
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
wrap.appendChild(canvas);
|
||||||
|
container.appendChild(wrap);
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: canvas.getContext('2d')!,
|
||||||
|
viewport,
|
||||||
|
}).promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,9 @@ MAIL_HOST=smtp.gmail.com
|
|||||||
MAIL_PORT=465
|
MAIL_PORT=465
|
||||||
MAIL_SECURE=true
|
MAIL_SECURE=true
|
||||||
MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
|
MAIL_AUTH_USER=phuocnguyenbp0@gmail.com
|
||||||
MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
|
MAIL_AUTH_PASS="cygi rtnv kkbw uuoz"
|
||||||
|
|
||||||
|
# GitHub OAuth — mỗi giáo viên kết nối tài khoản riêng (tạo OAuth App tại github.com/settings/developers)
|
||||||
|
GITHUB_OAUTH_CLIENT_ID=Ov23liRiArENu3uBwyDz
|
||||||
|
GITHUB_OAUTH_CLIENT_SECRET=f1d762d4061c83905bd13acaa8611ee2fb0ccb01
|
||||||
|
GITHUB_OAUTH_REDIRECT_URI=https://sv.rikkeiraia.org/api/auth/github/callback
|
||||||
90
server/internal/auth/github_token.go
Normal file
90
server/internal/auth/github_token.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitHubOAuthStateClaims struct {
|
||||||
|
StaffID uint `json:"staffId"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func IssueGitHubOAuthState(staffID uint) (string, error) {
|
||||||
|
claims := GitHubOAuthStateClaims{
|
||||||
|
StaffID: staffID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return t.SignedString([]byte(JWTSecret()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseGitHubOAuthState(state string) (uint, error) {
|
||||||
|
t, err := jwt.ParseWithClaims(state, &GitHubOAuthStateClaims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
return []byte(JWTSecret()), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
claims, ok := t.Claims.(*GitHubOAuthStateClaims)
|
||||||
|
if !ok || !t.Valid || claims.StaffID == 0 {
|
||||||
|
return 0, errors.New("invalid oauth state")
|
||||||
|
}
|
||||||
|
return claims.StaffID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenCipher() (cipher.AEAD, error) {
|
||||||
|
sum := sha256.Sum256([]byte(JWTSecret()))
|
||||||
|
block, err := aes.NewCipher(sum[:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cipher.NewGCM(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptSecret(plain string) (string, error) {
|
||||||
|
if plain == "" {
|
||||||
|
return "", errors.New("empty secret")
|
||||||
|
}
|
||||||
|
gcm, err := tokenCipher()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sealed := gcm.Seal(nonce, nonce, []byte(plain), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptSecret(encoded string) (string, error) {
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := tokenCipher()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||||
|
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user