add rescide
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m43s

This commit is contained in:
2026-07-29 12:32:01 +07:00
parent 3b80550c76
commit 42172db9f4
6 changed files with 445 additions and 2 deletions

View File

@@ -10,6 +10,8 @@ import { SystemTab } from './components/SystemTab';
import { MyAccountTab } from './components/AccountsTab'; import { MyAccountTab } from './components/AccountsTab';
import { StudentAffairsTab } from './components/StudentAffairsTab'; import { StudentAffairsTab } from './components/StudentAffairsTab';
import { ApplicationsTab } from './components/ApplicationsTab'; import { ApplicationsTab } from './components/ApplicationsTab';
import { RescideTab } from './components/RescideTab';
import { RescideAnnouncePopup } from './components/RescideAnnouncePopup';
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';
@@ -89,6 +91,14 @@ const IconApplications = () => (
</svg> </svg>
); );
const IconRescide = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
<line x1="14" y1="4" x2="10" y2="20" />
</svg>
);
const IconMyClass = () => ( const IconMyClass = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden> <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="M12 3 2 9l10 6 10-6-10-6Z" />
@@ -329,6 +339,16 @@ function App() {
<span className="nav-label">ng dụng</span> <span className="nav-label">ng dụng</span>
</button> </button>
</li> </li>
<li className="nav-item">
<button
type="button"
className={navBtn(route.tab === 'rescide' && !inWorkspace)}
onClick={() => setActiveTab('rescide')}
>
<span className="nav-icon"><IconRescide /></span>
<span className="nav-label">RESCIDE IDE</span>
</button>
</li>
<li className="nav-section" aria-hidden="true"> <li className="nav-section" aria-hidden="true">
<span className="nav-header">Hệ thống</span> <span className="nav-header">Hệ thống</span>
@@ -429,10 +449,12 @@ function App() {
{route.tab === 'profile' && <MyAccountTab />} {route.tab === 'profile' && <MyAccountTab />}
{route.tab === 'student-affairs' && <StudentAffairsTab />} {route.tab === 'student-affairs' && <StudentAffairsTab />}
{route.tab === 'applications' && <ApplicationsTab />} {route.tab === 'applications' && <ApplicationsTab />}
{route.tab === 'rescide' && <RescideTab />}
</> </>
)} )}
</div> </div>
</main> </main>
<RescideAnnouncePopup disabled={inWorkspace} />
<StaffChatSocket /> <StaffChatSocket />
<ChatWidget /> <ChatWidget />
</> </>

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import {
isRescidePopupHidden,
RESCIDE_DEFAULT_DOWNLOADS,
RESCIDE_FEATURES,
setRescidePopupHidden,
} from '../rescide';
import { navigate } from '../navigation';
type Props = {
/** Khi true, không hiện popup (ví dụ đang ở workspace lớp/thi). */
disabled?: boolean;
};
export const RescideAnnouncePopup: React.FC<Props> = ({ disabled }) => {
const { staff } = useAuth();
const [open, setOpen] = useState(false);
useEffect(() => {
if (disabled || !staff?.email) {
setOpen(false);
return;
}
setOpen(!isRescidePopupHidden(staff.email));
}, [staff?.email, disabled]);
if (!open || !staff?.email) return null;
const dismissOnce = () => setOpen(false);
const dismissForever = () => {
setRescidePopupHidden(staff.email, true);
setOpen(false);
};
const goTab = () => {
setOpen(false);
navigate('rescide');
};
return (
<div className="modal-overlay" style={{ zIndex: 1200 }} onClick={dismissOnce}>
<div
className="modal-container"
style={{ maxWidth: 520, width: '92%' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header" style={{ borderBottom: '1px solid var(--border-light)' }}>
<h2 className="modal-title" style={{ margin: 0 }}>
Thông báo: RESCIDE IDE
</h2>
<button type="button" className="modal-close-btn" aria-label="Đóng" onClick={dismissOnce}>
×
</button>
</div>
<div className="modal-body" style={{ padding: '1.25rem 1.5rem' }}>
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: '1rem' }}>
<strong style={{ color: 'var(--text-primary)' }}>RESCIDE</strong> IDE thi thực hành đang mở test thử đ
đánh giá cải thiện chức năng. Mục đích: môi trường IDE chuyên cho kỳ thi thực hành, chống gian lận cùng
RAIA DESKTOP.
</p>
<ul
style={{
margin: '0 0 1.25rem',
paddingLeft: '1.15rem',
fontSize: '0.875rem',
color: 'var(--text-secondary)',
lineHeight: 1.65,
}}
>
{RESCIDE_FEATURES.map((f) => (
<li key={f} style={{ marginBottom: '0.35rem' }}>
{f}
</li>
))}
</ul>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.25rem' }}>
{RESCIDE_DEFAULT_DOWNLOADS.map((d) => (
<a
key={d.downloadUrl}
href={d.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
style={{ justifyContent: 'space-between', textDecoration: 'none', width: '100%' }}
>
<span>{d.name}</span>
<span style={{ fontWeight: 700, color: 'var(--accent)' }}>Tải</span>
</a>
))}
</div>
</div>
<div
className="modal-footer"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
justifyContent: 'flex-end',
borderTop: '1px solid var(--border-light)',
padding: '1rem 1.5rem',
}}
>
<button type="button" className="btn btn-secondary" onClick={dismissForever}>
n với tài khoản này
</button>
<button type="button" className="btn btn-secondary" onClick={goTab}>
Xem chi tiết
</button>
<button type="button" className="btn btn-primary" onClick={dismissOnce}>
Đã hiểu
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,182 @@
import React, { useEffect, useState } from 'react';
import { api, type AppDownloadItem } from '../api';
import { useAuth } from '../auth/AuthContext';
import {
isRescideManager,
isRescidePopupHidden,
RESCIDE_DEFAULT_DOWNLOADS,
RESCIDE_FEATURES,
setRescidePopupHidden,
type RescideDownload,
} from '../rescide';
import { navigate } from '../navigation';
function mergeDownloads(fromApi: AppDownloadItem[]): RescideDownload[] {
const rescide = fromApi.filter((a) => a.name.toUpperCase().includes('RESCIDE'));
if (rescide.length > 0) {
return rescide.map((a) => ({
name: a.name,
description: a.description || '',
downloadUrl: a.downloadUrl,
platform: a.platform || '',
}));
}
return RESCIDE_DEFAULT_DOWNLOADS;
}
export const RescideTab: React.FC = () => {
const { staff } = useAuth();
const canManage = isRescideManager(staff?.email);
const [downloads, setDownloads] = useState<RescideDownload[]>(RESCIDE_DEFAULT_DOWNLOADS);
const [popupHidden, setPopupHidden] = useState(() => isRescidePopupHidden(staff?.email));
useEffect(() => {
setPopupHidden(isRescidePopupHidden(staff?.email));
}, [staff?.email]);
useEffect(() => {
let cancelled = false;
api
.listAppDownloads()
.then((res) => {
if (!cancelled) setDownloads(mergeDownloads(res.data || []));
})
.catch(() => {
if (!cancelled) setDownloads(RESCIDE_DEFAULT_DOWNLOADS);
});
return () => {
cancelled = true;
};
}, []);
const togglePopupHide = () => {
if (!staff?.email) return;
const next = !popupHidden;
setRescidePopupHidden(staff.email, next);
setPopupHidden(next);
};
return (
<div className="tab-page">
<div className="tab-page-toolbar">
<div className="page-header">
<div className="page-title">
<h1>RESCIDE IDE thi thực hành</h1>
<p>
Mở test thử đ đánh giá cải thiện chức năng. IDE chuyên dụng cho kỳ thi thực hành, chống gian lận
cùng RAIA DESKTOP.
</p>
</div>
</div>
</div>
<div className="tab-page-body tab-page-scroll">
<div
className="content-card"
style={{
marginBottom: '1.25rem',
padding: '1.25rem 1.5rem',
borderColor: 'rgba(187, 33, 38, 0.25)',
background: 'linear-gradient(135deg, #fff 0%, var(--accent-light) 100%)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '1rem', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: 'var(--accent)', letterSpacing: '0.04em', textTransform: 'uppercase', marginBottom: '0.35rem' }}>
Thông báo thử nghiệm
</div>
<p style={{ fontSize: '0.95rem', color: 'var(--text-secondary)', lineHeight: 1.55, margin: 0, maxWidth: 640 }}>
Vui lòng tải RESCIDE, dùng thử trong môi trường thi thực hành góp ý đ đi ngũ hoàn thiện sản phẩm.
</p>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={togglePopupHide}
title={popupHidden ? 'Hiện popup RESCIDE mỗi khi vào web' : 'Ẩn popup RESCIDE với tài khoản này'}
>
{popupHidden ? 'Hiện lại popup khi vào web' : 'Ẩn popup với tài khoản này'}
</button>
</div>
</div>
<div className="content-card" style={{ marginBottom: '1.25rem', padding: '1.5rem' }}>
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, marginBottom: '0.75rem' }}>Chức năng tiêu biểu</h2>
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', lineHeight: 1.7, fontSize: '0.95rem' }}>
{RESCIDE_FEATURES.map((f) => (
<li key={f} style={{ marginBottom: '0.4rem' }}>
{f}
</li>
))}
</ul>
</div>
<div className="content-card" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.05rem', fontWeight: 700, margin: 0 }}>Tải RESCIDE</h2>
{canManage && (
<button type="button" className="btn btn-secondary" onClick={() => navigate('applications')}>
Quản link tải (ng dụng)
</button>
)}
</div>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '1.25rem', lineHeight: 1.5 }}>
Chọn bản cài đt phù hợp hệ điều hành. Link tải đưc đng bộ từ khu vực ng dụng
{canManage ? ' — bạn có thể chỉnh sửa tại tab Ứng dụng.' : '.'}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
{downloads.map((d) => (
<a
key={`${d.name}-${d.downloadUrl}`}
href={d.downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="content-card"
style={{
padding: '1.25rem',
textDecoration: 'none',
color: 'inherit',
border: '1px solid var(--border-color)',
transition: 'var(--transition)',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(187, 33, 38, 0.35)';
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--border-color)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span
style={{
display: 'inline-flex',
alignSelf: 'flex-start',
fontSize: '0.7rem',
fontWeight: 700,
color: 'var(--accent)',
background: 'var(--accent-light)',
padding: '0.2rem 0.55rem',
borderRadius: 6,
}}
>
{d.platform || 'Download'}
</span>
<strong style={{ fontSize: '0.95rem', color: 'var(--text-primary)' }}>{d.name}</strong>
{d.description && (
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>{d.description}</span>
)}
<span style={{ marginTop: 'auto', paddingTop: '0.75rem', fontSize: '0.85rem', fontWeight: 600, color: 'var(--accent)' }}>
Tải về
</span>
</a>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
export type SystemSection = 'organization' | 'network' | 'templates' | 'seating'; export type SystemSection = 'organization' | 'network' | 'templates' | 'seating';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications'; export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications' | 'rescide';
export interface NavEntry { export interface NavEntry {
id: string; id: string;
@@ -30,6 +30,7 @@ export const TAB_LABELS: Record<TabId, string> = {
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', 'student-affairs': 'Công Tác Sinh Viên',
applications: 'Ứng Dụng', applications: 'Ứng Dụng',
rescide: 'RESCIDE IDE',
}; };
const HISTORY_KEY = 'sc_nav_history'; const HISTORY_KEY = 'sc_nav_history';
@@ -45,7 +46,8 @@ function isTabId(value: string | null): value is TabId {
value === 'system' || value === 'system' ||
value === 'profile' || value === 'profile' ||
value === 'student-affairs' || value === 'student-affairs' ||
value === 'applications' value === 'applications' ||
value === 'rescide'
); );
} }

67
management/src/rescide.ts Normal file
View File

@@ -0,0 +1,67 @@
/** RESCIDE IDE — thông báo & link tải (quản lý CRUD tại tab Ứng dụng, tài khoản super-admin). */
export const RESCIDE_SUPER_ADMIN_EMAIL = 'phuocntb@rikkeiacademy.com';
export const RESCIDE_POPUP_STORAGE_PREFIX = 'sc_rescide_popup_hidden:';
export function rescidePopupStorageKey(email: string) {
return `${RESCIDE_POPUP_STORAGE_PREFIX}${email.trim().toLowerCase()}`;
}
export function isRescidePopupHidden(email: string | undefined | null): boolean {
if (!email) return true;
try {
return localStorage.getItem(rescidePopupStorageKey(email)) === '1';
} catch {
return false;
}
}
export function setRescidePopupHidden(email: string, hidden: boolean) {
try {
const key = rescidePopupStorageKey(email);
if (hidden) localStorage.setItem(key, '1');
else localStorage.removeItem(key);
} catch {
/* ignore */
}
}
export function isRescideManager(email: string | undefined | null): boolean {
return email?.trim().toLowerCase() === RESCIDE_SUPER_ADMIN_EMAIL;
}
export type RescideDownload = {
name: string;
description: string;
downloadUrl: string;
platform: string;
};
export const RESCIDE_DEFAULT_DOWNLOADS: RescideDownload[] = [
{
name: 'RESCIDE (macOS Intel)',
description: 'IDE thi thực hành — macOS chip Intel',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg',
platform: 'macOS',
},
{
name: 'RESCIDE (macOS Apple Silicon)',
description: 'IDE thi thực hành — macOS chip Apple ARM',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg',
platform: 'macOS',
},
{
name: 'RESCIDE (Windows)',
description: 'IDE thi thực hành — Windows 10+',
downloadUrl: 'https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip',
platform: 'Windows',
},
];
export const RESCIDE_FEATURES = [
'Kiểm soát code thời gian thực, cộng hưởng cùng RAIA DESKTOP chặn gian lận.',
'Ngăn chặn paste code từ bên ngoài workspace.',
'Chặn trình duyệt online và extension AI.',
'Chặn Emmet / snippet code gian lận và nhiều chức năng ẩn khác.',
] as const;

View File

@@ -80,9 +80,61 @@ func AutoMigrate(db *gorm.DB) error {
if err := purgeLegacyWifiWithoutBSSID(db); err != nil { if err := purgeLegacyWifiWithoutBSSID(db); err != nil {
return err return err
} }
if err := seedRescideAppDownloads(db); err != nil {
log.Printf("Warning: seed RESCIDE app downloads: %v", err)
}
return migrateLegacyDiscoveredApps(db) return migrateLegacyDiscoveredApps(db)
} }
// seedRescideAppDownloads thêm link tải RESCIDE nếu chưa có (quản lý tiếp tại tab Ứng dụng — super-admin).
func seedRescideAppDownloads(db *gorm.DB) error {
type seedItem struct {
Name string
Description string
DownloadURL string
Platform string
}
items := []seedItem{
{
Name: "RESCIDE (macOS Intel)",
Description: "IDE thi thực hành — macOS chip Intel",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_intel-installer.dmg",
Platform: "macOS",
},
{
Name: "RESCIDE (macOS Apple Silicon)",
Description: "IDE thi thực hành — macOS chip Apple ARM",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE_arm-installer.dmg",
Platform: "macOS",
},
{
Name: "RESCIDE (Windows)",
Description: "IDE thi thực hành — Windows 10+",
DownloadURL: "https://save.rikkeiraia.org/RESCIDE-1.73.100-win.zip",
Platform: "Windows",
},
}
for _, item := range items {
var count int64
if err := db.Model(&models.AppDownload{}).Where("download_url = ?", item.DownloadURL).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
if err := db.Create(&models.AppDownload{
Name: item.Name,
Description: item.Description,
DownloadURL: item.DownloadURL,
Platform: item.Platform,
}).Error; err != nil {
return err
}
log.Printf("Seeded app download: %s", item.Name)
}
return nil
}
// Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK. // Bảng WiFi cũ dùng ssid_key làm PK; schema mới cần id + bssid_key — GORM không tự đổi PK.
func migrateLegacyWifiTables(db *gorm.DB) error { func migrateLegacyWifiTables(db *gorm.DB) error {
if db.Migrator().HasTable("accepted_wifis") { if db.Migrator().HasTable("accepted_wifis") {