git feature
This commit is contained in:
@@ -152,15 +152,6 @@ function App() {
|
||||
Phòng thi
|
||||
</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>
|
||||
</li>
|
||||
|
||||
<div className="nav-header">Hệ thống</div>
|
||||
<li className="nav-item">
|
||||
@@ -172,6 +163,15 @@ function App() {
|
||||
Đuôi email
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -81,6 +81,31 @@ export const apiAuth = {
|
||||
},
|
||||
};
|
||||
|
||||
export interface GitHubStatus {
|
||||
connected: boolean;
|
||||
githubLogin?: string;
|
||||
connectedAt?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
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 EmailDomainItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
@@ -586,6 +611,9 @@ export interface ExamRoomItem {
|
||||
endTime: string;
|
||||
allowedApps: string;
|
||||
quizUrl: string;
|
||||
gitRepoUrl?: string;
|
||||
gitBranch?: string;
|
||||
gitPublishUrl?: string;
|
||||
status: 'draft' | 'ready' | 'ended' | 'cancelled';
|
||||
studentCount: number;
|
||||
paperCount: number;
|
||||
@@ -752,4 +780,31 @@ export const apiExam = {
|
||||
return res.json() as Promise<{ data: ExamSubmission[] }>;
|
||||
},
|
||||
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) await parseError(res, 'Đẩy lên Git thất bại');
|
||||
return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||
|
||||
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||
const { changePassword, logout } = useAuth();
|
||||
@@ -127,22 +128,23 @@ export function EmailDomainsTab() {
|
||||
|
||||
export function MyAccountTab() {
|
||||
const { staff, changePassword } = useAuth();
|
||||
const gh = useGitHubConnection();
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [pwMsg, setPwMsg] = useState('');
|
||||
const [pwErr, setPwErr] = useState('');
|
||||
|
||||
const submitPw = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
setMsg('');
|
||||
setPwErr('');
|
||||
setPwMsg('');
|
||||
try {
|
||||
await changePassword(oldPw, newPw);
|
||||
setMsg('Đã đổi mật khẩu');
|
||||
setPwMsg('Đã đổi mật khẩu');
|
||||
setOldPw('');
|
||||
setNewPw('');
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Lỗi');
|
||||
setPwErr(e?.message || 'Lỗi');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,7 +154,7 @@ export function MyAccountTab() {
|
||||
<div className="page-stack">
|
||||
<header className="page-header">
|
||||
<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>
|
||||
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
@@ -161,6 +163,41 @@ export function MyAccountTab() {
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>{staff?.email}</p>
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
<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.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> → chọn phòng → tab <strong>Bài nộp</strong> → bấm <strong>Đẩy lên Git</strong>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
<h2 className="section-title">Đổi mật khẩu</h2>
|
||||
<form onSubmit={submitPw} className="login-form" style={{ maxWidth: 400 }}>
|
||||
@@ -172,8 +209,8 @@ export function MyAccountTab() {
|
||||
<span>Mật khẩu mới</span>
|
||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} required minLength={8} />
|
||||
</label>
|
||||
{err && <div className="login-error">{err}</div>}
|
||||
{msg && <div className="login-success">{msg}</div>}
|
||||
{pwErr && <div className="login-error">{pwErr}</div>}
|
||||
{pwMsg && <div className="login-success">{pwMsg}</div>}
|
||||
<button type="submit" className="btn btn-primary">Lưu</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
type ExamSubmission,
|
||||
type StudentItem,
|
||||
} from '../api';
|
||||
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||
import { navigate, pushNav } from '../navigation';
|
||||
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
|
||||
import { pushNav } from '../navigation';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { NavHistoryBar } from './NavHistoryBar';
|
||||
@@ -49,6 +50,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [end, setEnd] = useState('');
|
||||
const [allowedApps, setAllowedApps] = useState(DEFAULT_EXAM_ALLOWED_APPS);
|
||||
const [quizUrl, setQuizUrl] = useState('');
|
||||
const [gitPublishUrl, setGitPublishUrl] = useState('');
|
||||
const [bundlingSubs, setBundlingSubs] = useState(false);
|
||||
const [publishingGit, setPublishingGit] = useState(false);
|
||||
const gh = useGitHubConnection();
|
||||
const [editable, setEditable] = useState(false);
|
||||
const [prepEditable, setPrepEditable] = useState(false);
|
||||
const [displayStatus, setDisplayStatus] = useState('draft');
|
||||
@@ -87,6 +92,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
setEnd(toLocalInput(data.room.endTime));
|
||||
setAllowedApps(data.room.allowedApps || DEFAULT_EXAM_ALLOWED_APPS);
|
||||
setQuizUrl(data.room.quizUrl || '');
|
||||
setGitPublishUrl(data.room.gitPublishUrl || '');
|
||||
setEditable(data.editable);
|
||||
setPrepEditable(data.prepEditable ?? data.editable);
|
||||
setDisplayStatus(data.displayStatus);
|
||||
@@ -230,6 +236,39 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadAllSubs = async () => {
|
||||
setErr(''); setMsg('');
|
||||
setBundlingSubs(true);
|
||||
try {
|
||||
await apiExam.downloadAllSubmissions(examId, roomName || `phong_thi_${examId}`);
|
||||
setMsg('Đã tải ZIP gộp — giải nén sẽ thấy từng folder bài làm theo sinh viên.');
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Tải thất bại');
|
||||
} finally {
|
||||
setBundlingSubs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const publishToGit = async () => {
|
||||
if (!gh.connected) {
|
||||
setErr('Kết nối GitHub trong Tài khoản của tôi (sidebar dưới cùng) trước khi đẩy.');
|
||||
return;
|
||||
}
|
||||
setErr(''); setMsg('');
|
||||
setPublishingGit(true);
|
||||
try {
|
||||
const res = await apiExam.publishSubmissionsGit(examId);
|
||||
const openUrl = res.openUrl || res.gitPublishUrl || res.url;
|
||||
setGitPublishUrl(openUrl);
|
||||
setMsg(res.message || 'Đã đẩy bài nộp lên GitHub');
|
||||
if (openUrl) window.open(openUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Đẩy Git thất bại');
|
||||
} finally {
|
||||
setPublishingGit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setErr(''); setMsg('');
|
||||
try {
|
||||
@@ -408,6 +447,31 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<span>Link trắc nghiệm</span>
|
||||
<input value={quizUrl} onChange={(e) => setQuizUrl(e.target.value)} placeholder="https://..." disabled={!editable} />
|
||||
</label>
|
||||
<div className="exam-git-settings">
|
||||
<p className="exam-git-status-line">
|
||||
GitHub:{' '}
|
||||
{gh.connected ? (
|
||||
<strong>@{gh.githubLogin}</strong>
|
||||
) : (
|
||||
<>
|
||||
<span className="exam-git-not-connected">chưa kết nối</span>
|
||||
{' — '}
|
||||
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
|
||||
Kết nối trong Tài khoản
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="exam-git-hint">
|
||||
Bấm <strong>Đẩy lên Git</strong> sẽ tự tạo repo theo tên phòng thi — mỗi sinh viên một folder, giống ZIP tải về.
|
||||
</p>
|
||||
{gitPublishUrl && (
|
||||
<p className="exam-git-last">
|
||||
Repo gần nhất:{' '}
|
||||
<a href={gitPublishUrl} target="_blank" rel="noreferrer">{gitPublishUrl}</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label className="login-field">
|
||||
<span>Bắt đầu</span>
|
||||
<input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} disabled={!editable} />
|
||||
@@ -766,6 +830,38 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-logs-panel">
|
||||
{submissions.length > 0 && (
|
||||
<div className="exam-submissions-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={bundlingSubs}
|
||||
onClick={downloadAllSubs}
|
||||
>
|
||||
{bundlingSubs ? 'Đang gói ZIP...' : '📦 Tải tất cả (ZIP gộp)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={publishingGit || !gh.connected}
|
||||
onClick={publishToGit}
|
||||
title={gh.connected ? 'Tạo repo + đẩy từng folder sinh viên lên GitHub' : 'Kết nối GitHub trong Tài khoản trước'}
|
||||
>
|
||||
{publishingGit ? 'Đang đẩy Git...' : '🔗 Đẩy lên Git'}
|
||||
</button>
|
||||
{!gh.connected && (
|
||||
<span className="exam-submissions-toolbar-hint">
|
||||
<button type="button" className="link-btn" onClick={() => navigate('profile')}>
|
||||
Kết nối GitHub
|
||||
</button>
|
||||
{' '}trong Tài khoản của tôi trước khi đẩy.
|
||||
</span>
|
||||
)}
|
||||
<span className="exam-submissions-toolbar-hint">
|
||||
ZIP gộp: mỗi sinh viên một folder — giải nén là chấm ngay.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="attendance-table-scroll table-wrapper" style={{ border: 'none' }}>
|
||||
{submissions.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: '300px' }}>
|
||||
|
||||
86
management/src/hooks/useGitHubConnection.ts
Normal file
86
management/src/hooks/useGitHubConnection.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiGitHub } from '../api';
|
||||
|
||||
export function useGitHubConnection() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [githubLogin, setGithubLogin] = useState('');
|
||||
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 || '');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConnected(false);
|
||||
setGithubLogin('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
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('');
|
||||
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,
|
||||
connecting,
|
||||
error,
|
||||
message,
|
||||
setError,
|
||||
setMessage,
|
||||
refresh,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -4261,3 +4261,94 @@ input:checked + .slider:before {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.exam-submissions-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.exam-submissions-toolbar-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.exam-git-settings {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed var(--border-light);
|
||||
}
|
||||
|
||||
.exam-git-oauth-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.exam-git-connected {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exam-git-connected strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exam-git-status-line {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exam-git-not-connected {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.exam-git-branch-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.exam-git-branch-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exam-git-branch-input {
|
||||
width: 120px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.exam-git-hint {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.exam-git-hint code {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.exam-git-last {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.exam-git-last a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user