add docs
All checks were successful
Deploy on Master Change / deploy (push) Successful in 44s

This commit is contained in:
2026-07-01 14:58:46 +07:00
parent dd766206f7
commit 0b02cc82b5
9 changed files with 992 additions and 2 deletions

405
landing_page/index.html Normal file
View 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>&copy; 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 = window.location.origin;
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}</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/guide-links`);
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 &rarr;</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>

View File

@@ -8,6 +8,7 @@ import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
import { SystemTab } from './components/SystemTab';
import { MyAccountTab } from './components/AccountsTab';
import { StudentAffairsTab } from './components/StudentAffairsTab';
import { ApplicationsTab } from './components/ApplicationsTab';
import { ChatWidget } from './components/ChatWidget';
import { StaffChatSocket } from './hooks/useStaffChatSocket';
import { ClassWorkspace } from './components/ClassWorkspace';
@@ -75,6 +76,15 @@ const IconStudentAffairs = () => (
</svg>
);
const IconApplications = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<path d="M3 14h7v7H3z" />
</svg>
);
const IconMenu = () => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
@@ -219,6 +229,15 @@ function App() {
Công Tác Sinh Viên
</button>
</li>
<li className="nav-item">
<button
className={navBtn(route.tab === 'applications' && !inWorkspace)}
onClick={() => setActiveTab('applications')}
>
<span className="nav-icon"><IconApplications /></span>
ng Dụng
</button>
</li>
<div className="nav-header">Hệ thống</div>
<li className="nav-item">
@@ -286,6 +305,7 @@ function App() {
{route.tab === 'system' && <SystemTab />}
{route.tab === 'profile' && <MyAccountTab />}
{route.tab === 'student-affairs' && <StudentAffairsTab />}
{route.tab === 'applications' && <ApplicationsTab />}
</>
)}
</div>

View File

@@ -277,6 +277,16 @@ export interface GuideLinkItem {
url: string;
}
export interface AppDownloadItem {
id: number;
createdAt: string;
updatedAt: string;
name: string;
description: string;
downloadUrl: string;
platform: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
@@ -397,6 +407,33 @@ export const api = {
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();
},
getClasses: async (params: {
page: number;
pageSize: number;

View File

@@ -0,0 +1,354 @@
import React, { useEffect, useState } from 'react';
import { api, type AppDownloadItem } from '../api';
import { useAuth } from '../auth/AuthContext';
const IconDownload = () => (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" 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 [apps, setApps] = useState<AppDownloadItem[]>([]);
const [loading, setLoading] = useState(true);
// Modal states
const [showModal, setShowModal] = useState(false);
const [editingApp, setEditingApp] = useState<AppDownloadItem | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [downloadUrl, setDownloadUrl] = useState('');
const [platform, setPlatform] = useState('Windows');
const [saving, setSaving] = useState(false);
const fetchApps = async () => {
try {
setLoading(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 {
setLoading(false);
}
};
useEffect(() => {
fetchApps();
}, []);
const handleOpenAdd = () => {
setEditingApp(null);
setName('');
setDescription('');
setDownloadUrl('');
setPlatform('Windows');
setShowModal(true);
};
const handleOpenEdit = (app: AppDownloadItem) => {
setEditingApp(app);
setName(app.name);
setDescription(app.description);
setDownloadUrl(app.downloadUrl);
setPlatform(app.platform);
setShowModal(true);
};
const handleSave = async () => {
if (!name.trim() || !downloadUrl.trim()) {
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
return;
}
try {
setSaving(true);
if (editingApp) {
await api.updateAppDownload(editingApp.id, name.trim(), description.trim(), downloadUrl.trim(), platform);
} else {
await api.createAppDownload(name.trim(), description.trim(), downloadUrl.trim(), platform);
}
setShowModal(false);
await fetchApps();
} catch (err: any) {
alert(err.message || 'Lưu ứng dụng thất bại');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: number, appName: 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 "${appName}" không?`)) {
try {
await api.deleteAppDownload(id);
await fetchApps();
} catch (err: any) {
alert(err.message || 'Xóa ứng dụng thất bại');
}
}
};
return (
<div className="tab-page">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">Quản ng Dụng</h1>
<p className="page-desc">Quản link tải các công cụ học tập hỗ trợ sinh viên.</p>
</div>
{isSuperAdmin && (
<button className="btn btn-primary" onClick={handleOpenAdd}>
+ Thêm ng dụng
</button>
)}
</header>
<div className="tab-page-body tab-page-scroll">
<div className="content-card">
{loading && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', padding: '3rem 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>
)}
{!loading && apps.length === 0 && (
<div style={{ textAlign: 'center', padding: '3rem 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>Chưa ng dụng nào đưc cấu hình tải.</p>
{isSuperAdmin && (
<button className="btn btn-secondary" onClick={handleOpenAdd} style={{ marginTop: '0.75rem' }}>
Thêm ng dụng đu tiên
</button>
)}
</div>
)}
{!loading && apps.length > 0 && (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Nền tảng</th>
<th>ng dụng</th>
<th> tả</th>
<th>Đưng dẫn tải</th>
{isSuperAdmin && <th style={{ textAlign: 'right' }}>Thao tác</th>}
</tr>
</thead>
<tbody>
{apps.map((app) => (
<tr key={app.id}>
<td style={{ width: '120px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<PlatformIcon platform={app.platform} />
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>{app.platform}</span>
</div>
</td>
<td style={{ width: '220px', fontWeight: 700, color: 'var(--text-primary)' }}>
{app.name}
</td>
<td style={{ color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
{app.description || '—'}
</td>
<td>
<a
href={app.downloadUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
color: 'var(--accent)',
fontSize: '0.85rem',
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={{ textAlign: 'right', width: '130px' }}>
<div style={{ display: 'inline-flex', gap: '6px' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleOpenEdit(app)}
>
Sửa
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleDelete(app.id, app.name)}
style={{ color: 'var(--danger)', borderColor: 'rgba(214, 48, 49, 0.2)' }}
>
Xóa
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(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={() => setShowModal(false)} aria-label="Đóng">&times;</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)' }}>Tên ng dụng</span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="VD: Simple Care Client v1.2"
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)' }}>Nền tảng</span>
<select
value={platform}
onChange={e => setPlatform(e.target.value)}
style={{
width: '100%',
padding: '0.55rem 0.75rem',
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)' }}> tả ngắn</span>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="VD: Dành cho máy Windows 64-bit"
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 tải</span>
<input
type="url"
value={downloadUrl}
onChange={e => setDownloadUrl(e.target.value)}
placeholder="https://..."
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={handleSave}
disabled={saving}
>
{saving ? 'Đang lưu...' : 'Lưu lại'}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
export type SystemSection = 'organization' | 'network' | 'templates';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs';
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile' | 'student-affairs' | 'applications';
export interface NavEntry {
id: string;
@@ -28,6 +28,7 @@ export const TAB_LABELS: Record<TabId, string> = {
system: 'Hệ thống',
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';
@@ -42,7 +43,8 @@ function isTabId(value: string | null): value is TabId {
value === 'exams' ||
value === 'system' ||
value === 'profile' ||
value === 'student-affairs'
value === 'student-affairs' ||
value === 'applications'
);
}

View File

@@ -67,6 +67,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.StaffGitHubRepo{},
&models.SystemSetting{},
&models.GuideLink{},
&models.AppDownload{},
); err != nil {
return err
}

View File

@@ -0,0 +1,148 @@
package handlers
import (
"strconv"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// GET /api/public/app-downloads
func ListAppDownloadsPublicHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var apps []models.AppDownload
if err := db.Order("created_at desc").Find(&apps).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách ứng dụng"})
}
return c.JSON(fiber.Map{"data": apps})
}
}
// GET /api/app-downloads
func ListAppDownloadsHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var apps []models.AppDownload
if err := db.Order("created_at desc").Find(&apps).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách ứng dụng"})
}
return c.JSON(fiber.Map{"data": apps})
}
}
// POST /api/app-downloads
func CreateAppDownloadHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
type Request struct {
Name string `json:"name"`
Description string `json:"description"`
DownloadURL string `json:"downloadUrl"`
Platform string `json:"platform"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Name == "" || req.DownloadURL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tên ứng dụng và đường dẫn tải không được để trống"})
}
app := models.AppDownload{
Name: req.Name,
Description: req.Description,
DownloadURL: req.DownloadURL,
Platform: req.Platform,
}
if err := db.Create(&app).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Lưu ứng dụng thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": app})
}
}
// PUT /api/app-downloads/:id
func UpdateAppDownloadHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var app models.AppDownload
if err := db.First(&app, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy ứng dụng"})
}
type Request struct {
Name string `json:"name"`
Description string `json:"description"`
DownloadURL string `json:"downloadUrl"`
Platform string `json:"platform"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Name == "" || req.DownloadURL == "" {
return c.Status(400).JSON(fiber.Map{"error": "Tên ứng dụng và đường dẫn tải không được để trống"})
}
app.Name = req.Name
app.Description = req.Description
app.DownloadURL = req.DownloadURL
app.Platform = req.Platform
if err := db.Save(&app).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật ứng dụng thất bại"})
}
return c.JSON(fiber.Map{"ok": true, "data": app})
}
}
// DELETE /api/app-downloads/:id
func DeleteAppDownloadHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
if !isSuperAdmin(c) {
return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"})
}
idParam := c.Params("id")
id, err := strconv.ParseUint(idParam, 10, 32)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid ID"})
}
var app models.AppDownload
if err := db.First(&app, uint(id)).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy ứng dụng"})
}
if err := db.Delete(&app).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Xóa ứng dụng thất bại"})
}
return c.JSON(fiber.Map{"ok": true})
}
}
// GET /api/public/guide-links
func ListGuideLinksPublicHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
var links []models.GuideLink
if err := db.Order("created_at desc").Find(&links).Error; err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu"})
}
return c.JSON(fiber.Map{"data": links})
}
}

View File

@@ -199,4 +199,17 @@ type GuideLink struct {
func (GuideLink) TableName() string { return "guide_links" }
// AppDownload đại diện cho ứng dụng và link tải
type AppDownload struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Name string `gorm:"column:name;size:255;not null" json:"name"`
Description string `gorm:"column:description;size:512" json:"description"`
DownloadURL string `gorm:"column:download_url;size:1024;not null" json:"downloadUrl"`
Platform string `gorm:"column:platform;size:64" json:"platform"` // "Windows", "macOS", "Linux", "Khác"
}
func (AppDownload) TableName() string { return "app_downloads" }

View File

@@ -107,6 +107,9 @@ func main() {
return c.JSON(fiber.Map{"ok": true})
})
api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(gormDB))
api.Get("/public/guide-links", handlers.ListGuideLinksPublicHandler(gormDB))
// Management — yêu cầu đăng nhập staff
staff := api.Group("", middleware.RequireStaff())
staff.Get("/auth/me", handlers.MeStaffHandler(gormDB))
@@ -207,6 +210,11 @@ func main() {
staff.Put("/guide-links/:id", handlers.UpdateGuideLinkHandler(gormDB))
staff.Delete("/guide-links/:id", handlers.DeleteGuideLinkHandler(gormDB))
staff.Get("/app-downloads", handlers.ListAppDownloadsHandler(gormDB))
staff.Post("/app-downloads", handlers.CreateAppDownloadHandler(gormDB))
staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(gormDB))
staff.Delete("/app-downloads/:id", handlers.DeleteAppDownloadHandler(gormDB))
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
@@ -215,6 +223,8 @@ func main() {
}
}()
app.Static("/", "../landing_page")
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s...", port)
if err := app.Listen(":" + port); err != nil {