This commit is contained in:
@@ -358,7 +358,7 @@
|
||||
const listDiv = document.getElementById('guides-list');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}/api/public/guide-links`);
|
||||
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();
|
||||
|
||||
@@ -287,6 +287,14 @@ export interface AppDownloadItem {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface AppGuideItem {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
@@ -434,6 +442,33 @@ export const api = {
|
||||
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: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, type AppDownloadItem, type GuideLinkItem } from '../api';
|
||||
import { api, type AppDownloadItem, type AppGuideItem } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
// Icons
|
||||
@@ -54,7 +54,7 @@ export const ApplicationsTab: React.FC = () => {
|
||||
const [appsLoading, setAppsLoading] = useState(true);
|
||||
|
||||
// Guides state
|
||||
const [guides, setGuides] = useState<GuideLinkItem[]>([]);
|
||||
const [guides, setGuides] = useState<AppGuideItem[]>([]);
|
||||
const [guidesLoading, setGuidesLoading] = useState(true);
|
||||
|
||||
// App Modal states
|
||||
@@ -68,7 +68,7 @@ export const ApplicationsTab: React.FC = () => {
|
||||
|
||||
// Guide Modal states
|
||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||
const [editingGuide, setEditingGuide] = useState<GuideLinkItem | null>(null);
|
||||
const [editingGuide, setEditingGuide] = useState<AppGuideItem | null>(null);
|
||||
const [guideTitle, setGuideTitle] = useState('');
|
||||
const [guideUrl, setGuideUrl] = useState('');
|
||||
const [guideSaving, setGuideSaving] = useState(false);
|
||||
@@ -88,10 +88,10 @@ export const ApplicationsTab: React.FC = () => {
|
||||
const fetchGuides = async () => {
|
||||
try {
|
||||
setGuidesLoading(true);
|
||||
const res = await api.listGuideLinks();
|
||||
const res = await api.listAppGuides();
|
||||
setGuides(res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Không thể tải danh sách hướng dẫn:', err);
|
||||
console.error('Không thể tải danh sách hướng dẫn sinh viên:', err);
|
||||
} finally {
|
||||
setGuidesLoading(false);
|
||||
}
|
||||
@@ -117,7 +117,6 @@ export const ApplicationsTab: React.FC = () => {
|
||||
setAppName(app.name);
|
||||
setAppDescription(app.description);
|
||||
setAppDownloadUrl(app.downloadUrl);
|
||||
// Standardize representation to 'MacOS'
|
||||
setAppPlatform(app.platform === 'macOS' ? 'MacOS' : app.platform);
|
||||
setShowAppModal(true);
|
||||
};
|
||||
@@ -127,7 +126,6 @@ export const ApplicationsTab: React.FC = () => {
|
||||
alert('Vui lòng nhập tên ứng dụng và đường dẫn tải.');
|
||||
return;
|
||||
}
|
||||
// Force write first letters uppercase
|
||||
let finalPlatform = appPlatform;
|
||||
if (finalPlatform.toLowerCase() === 'macos') {
|
||||
finalPlatform = 'MacOS';
|
||||
@@ -168,7 +166,7 @@ export const ApplicationsTab: React.FC = () => {
|
||||
setShowGuideModal(true);
|
||||
};
|
||||
|
||||
const handleOpenEditGuide = (guide: GuideLinkItem) => {
|
||||
const handleOpenEditGuide = (guide: AppGuideItem) => {
|
||||
setEditingGuide(guide);
|
||||
setGuideTitle(guide.title);
|
||||
setGuideUrl(guide.url);
|
||||
@@ -183,9 +181,9 @@ export const ApplicationsTab: React.FC = () => {
|
||||
try {
|
||||
setGuideSaving(true);
|
||||
if (editingGuide) {
|
||||
await api.updateGuideLink(editingGuide.id, guideTitle.trim(), guideUrl.trim());
|
||||
await api.updateAppGuide(editingGuide.id, guideTitle.trim(), guideUrl.trim());
|
||||
} else {
|
||||
await api.createGuideLink(guideTitle.trim(), guideUrl.trim());
|
||||
await api.createAppGuide(guideTitle.trim(), guideUrl.trim());
|
||||
}
|
||||
setShowGuideModal(false);
|
||||
await fetchGuides();
|
||||
@@ -197,9 +195,9 @@ export const ApplicationsTab: React.FC = () => {
|
||||
};
|
||||
|
||||
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 "${title}"?`)) {
|
||||
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.deleteGuideLink(id);
|
||||
await api.deleteAppGuide(id);
|
||||
await fetchGuides();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Xóa hướng dẫn thất bại');
|
||||
@@ -229,7 +227,6 @@ export const ApplicationsTab: React.FC = () => {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Sub-tab Switch Header */}
|
||||
<div style={{ padding: '0 2rem' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
@@ -388,10 +385,10 @@ export const ApplicationsTab: React.FC = () => {
|
||||
<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 nào.</p>
|
||||
<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 tài liệu hướng dẫn đầu tiên
|
||||
Thêm hướng dẫn sinh viên đầu tiên
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -403,7 +400,7 @@ export const ApplicationsTab: React.FC = () => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '1.15rem 1.5rem', width: '15%', fontWeight: 700, color: 'var(--text-secondary)' }}>Loại tài liệu</th>
|
||||
<th style={{ padding: '1.15rem 1.5rem', width: '50%', fontWeight: 700, color: 'var(--text-secondary)' }}>Tiêu đề hướng dẫn</th>
|
||||
<th style={{ padding: '1.15rem 1.5rem', width: '50%', fontWeight: 700, color: 'var(--text-secondary)' }}>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)' }}>Đườ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>
|
||||
@@ -590,7 +587,7 @@ export const ApplicationsTab: React.FC = () => {
|
||||
<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' : 'Thêm tài liệu hướng dẫn'}</h2>
|
||||
<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' }}>
|
||||
@@ -601,7 +598,7 @@ export const ApplicationsTab: React.FC = () => {
|
||||
type="text"
|
||||
value={guideTitle}
|
||||
onChange={e => setGuideTitle(e.target.value)}
|
||||
placeholder="VD: Hướng dẫn cài đặt và đồng bộ dữ liệu"
|
||||
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',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import { api, type GuideLinkItem } from '../api';
|
||||
import type { StatsResponse } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
interface DashboardTabProps {
|
||||
onNavigate: (tab: string) => void;
|
||||
@@ -37,10 +38,36 @@ const IconAlert = () => (
|
||||
);
|
||||
|
||||
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 [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
@@ -54,8 +81,55 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<div className="tab-page">
|
||||
<div className="tab-page-toolbar">
|
||||
@@ -138,9 +212,179 @@ export const DashboardTab: React.FC<DashboardTabProps> = ({ onNavigate }) => {
|
||||
</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>
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.SystemSetting{},
|
||||
&models.GuideLink{},
|
||||
&models.AppDownload{},
|
||||
&models.AppGuide{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
129
server/internal/handlers/handlers_app_guides.go
Normal file
129
server/internal/handlers/handlers_app_guides.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"server/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GET /api/public/app-guides
|
||||
func ListAppGuidesPublicHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var guides []models.AppGuide
|
||||
if err := db.Order("created_at desc").Find(&guides).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu hướng dẫn"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": guides})
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/app-guides
|
||||
func ListAppGuidesHandler(db *gorm.DB) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var guides []models.AppGuide
|
||||
if err := db.Order("created_at desc").Find(&guides).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Không thể tải danh sách tài liệu hướng dẫn"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": guides})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/app-guides
|
||||
func CreateAppGuideHandler(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 {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
var req Request
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
if req.Title == "" || req.URL == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
|
||||
}
|
||||
|
||||
guide := models.AppGuide{
|
||||
Title: req.Title,
|
||||
URL: req.URL,
|
||||
}
|
||||
if err := db.Create(&guide).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Lưu tài liệu hướng dẫn thất bại"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "data": guide})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/app-guides/:id
|
||||
func UpdateAppGuideHandler(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 guide models.AppGuide
|
||||
if err := db.First(&guide, uint(id)).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu hướng dẫn"})
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
var req Request
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
if req.Title == "" || req.URL == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "Tiêu đề và đường dẫn không được để trống"})
|
||||
}
|
||||
|
||||
guide.Title = req.Title
|
||||
guide.URL = req.URL
|
||||
|
||||
if err := db.Save(&guide).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Cập nhật tài liệu hướng dẫn thất bại"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "data": guide})
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/app-guides/:id
|
||||
func DeleteAppGuideHandler(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 guide models.AppGuide
|
||||
if err := db.First(&guide, uint(id)).Error; err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy tài liệu hướng dẫn"})
|
||||
}
|
||||
|
||||
if err := db.Delete(&guide).Error; err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "Xóa tài liệu hướng dẫn thất bại"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
}
|
||||
@@ -212,4 +212,15 @@ type AppDownload struct {
|
||||
|
||||
func (AppDownload) TableName() string { return "app_downloads" }
|
||||
|
||||
// AppGuide đại diện cho tài liệu/video hướng dẫn ứng dụng dành cho sinh viên
|
||||
type AppGuide struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Title string `gorm:"column:title;size:512;not null" json:"title"`
|
||||
URL string `gorm:"column:url;size:1024;not null" json:"url"`
|
||||
}
|
||||
|
||||
func (AppGuide) TableName() string { return "app_guides" }
|
||||
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ func main() {
|
||||
|
||||
api.Get("/public/app-downloads", handlers.ListAppDownloadsPublicHandler(gormDB))
|
||||
api.Get("/public/guide-links", handlers.ListGuideLinksPublicHandler(gormDB))
|
||||
api.Get("/public/app-guides", handlers.ListAppGuidesPublicHandler(gormDB))
|
||||
|
||||
// Management — yêu cầu đăng nhập staff
|
||||
staff := api.Group("", middleware.RequireStaff())
|
||||
@@ -215,6 +216,11 @@ func main() {
|
||||
staff.Put("/app-downloads/:id", handlers.UpdateAppDownloadHandler(gormDB))
|
||||
staff.Delete("/app-downloads/:id", handlers.DeleteAppDownloadHandler(gormDB))
|
||||
|
||||
staff.Get("/app-guides", handlers.ListAppGuidesHandler(gormDB))
|
||||
staff.Post("/app-guides", handlers.CreateAppGuideHandler(gormDB))
|
||||
staff.Put("/app-guides/:id", handlers.UpdateAppGuideHandler(gormDB))
|
||||
staff.Delete("/app-guides/:id", handlers.DeleteAppGuideHandler(gormDB))
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
Reference in New Issue
Block a user