fix up git
This commit is contained in:
@@ -86,6 +86,7 @@ export interface GitHubStatus {
|
||||
githubLogin?: string;
|
||||
connectedAt?: string;
|
||||
scope?: string;
|
||||
canDeleteRepos?: boolean;
|
||||
}
|
||||
|
||||
export const apiGitHub = {
|
||||
@@ -106,6 +107,37 @@ export const apiGitHub = {
|
||||
},
|
||||
};
|
||||
|
||||
export interface GitHubRepoItem {
|
||||
key: string;
|
||||
ownerLogin: string;
|
||||
repoName: string;
|
||||
repoHtmlUrl: string;
|
||||
private?: boolean;
|
||||
examRoomId?: number;
|
||||
examRoomName?: string;
|
||||
studentLabel?: string;
|
||||
publishMode?: 'room' | 'student' | '';
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
fromSimpleCare?: boolean;
|
||||
}
|
||||
|
||||
export const apiGitHubRepos = {
|
||||
list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => {
|
||||
const res = await staffFetch('/auth/github/repos');
|
||||
if (!res.ok) await parseError(res, 'Không tải danh sách repo');
|
||||
return res.json();
|
||||
},
|
||||
bulkDelete: async (repos: string[]) => {
|
||||
const res = await staffFetch('/auth/github/repos/bulk-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ repos }),
|
||||
});
|
||||
if (!res.ok) await parseError(res, 'Xóa repo thất bại');
|
||||
return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>;
|
||||
},
|
||||
};
|
||||
|
||||
export interface EmailDomainItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
@@ -658,6 +690,8 @@ export interface ExamSubmission {
|
||||
createdAt: string;
|
||||
fullName: string;
|
||||
studentCode: string;
|
||||
gitRepoUrl?: string;
|
||||
gitPublishUrl?: string;
|
||||
}
|
||||
|
||||
export const apiExam = {
|
||||
@@ -807,4 +841,16 @@ export const apiExam = {
|
||||
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 }>;
|
||||
},
|
||||
publishSubmissionsGitStudents: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' });
|
||||
if (!res.ok) await parseError(res, 'Đẩy repo từng SV thất bại');
|
||||
return res.json() as Promise<{
|
||||
ok: boolean;
|
||||
published: number;
|
||||
openUrl?: string;
|
||||
message: string;
|
||||
warnings?: string[];
|
||||
studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[];
|
||||
}>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||
import { GitHubReposPanel } from './GitHubReposPanel';
|
||||
|
||||
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||
const { changePassword, logout } = useAuth();
|
||||
@@ -177,6 +178,11 @@ export function MyAccountTab() {
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={gh.disconnect}>
|
||||
Ngắt kết nối
|
||||
</button>
|
||||
{!gh.canDeleteRepos && (
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={gh.connecting} onClick={gh.connect}>
|
||||
{gh.connecting ? 'Đang mở GitHub...' : 'Cấp quyền xóa repo'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
@@ -189,13 +195,24 @@ export function MyAccountTab() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{gh.connected && !gh.canDeleteRepos && (
|
||||
<div className="login-error" style={{ marginTop: '0.75rem' }}>
|
||||
Token hiện tại chưa có quyền <strong>delete_repo</strong> — không xóa được repo. Bấm <strong>Cấp quyền xóa repo</strong> (hoặc ngắt kết nối rồi kết nối lại).
|
||||
</div>
|
||||
)}
|
||||
{gh.error && <div className="login-error">{gh.error}</div>}
|
||||
{gh.message && <div className="login-success">{gh.message}</div>}
|
||||
{!gh.connected && (
|
||||
<p className="exam-git-hint">
|
||||
Sau khi kết nối, vào <strong>Phòng thi</strong> → chọn phòng → tab <strong>Bài nộp</strong> → bấm <strong>Đẩy lên Git</strong>.
|
||||
Sau khi kết nối, vào <strong>Phòng thi</strong> → tab <strong>Bài nộp</strong> → đẩy Git (gộp hoặc từng sinh viên).
|
||||
</p>
|
||||
)}
|
||||
{gh.connected && (
|
||||
<>
|
||||
<h3 className="section-subtitle" style={{ marginTop: '1rem' }}>Repo đã tạo</h3>
|
||||
<GitHubReposPanel connected={gh.connected} canDeleteRepos={gh.canDeleteRepos} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [gitPublishUrl, setGitPublishUrl] = useState('');
|
||||
const [bundlingSubs, setBundlingSubs] = useState(false);
|
||||
const [publishingGit, setPublishingGit] = useState(false);
|
||||
const [publishingGitStudents, setPublishingGitStudents] = useState(false);
|
||||
const gh = useGitHubConnection();
|
||||
const [editable, setEditable] = useState(false);
|
||||
const [prepEditable, setPrepEditable] = useState(false);
|
||||
@@ -269,6 +270,26 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const publishToGitStudents = async () => {
|
||||
if (!gh.connected) {
|
||||
setErr('Kết nối GitHub trong Tài khoản của tôi trước khi đẩy.');
|
||||
return;
|
||||
}
|
||||
setErr(''); setMsg('');
|
||||
setPublishingGitStudents(true);
|
||||
try {
|
||||
const res = await apiExam.publishSubmissionsGitStudents(examId);
|
||||
setMsg(res.message || 'Đã tạo repo từng sinh viên');
|
||||
if (res.openUrl) window.open(res.openUrl, '_blank', 'noopener,noreferrer');
|
||||
const subs = await apiExam.listSubmissions(examId);
|
||||
setSubmissions(subs.data);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Đẩy Git thất bại');
|
||||
} finally {
|
||||
setPublishingGitStudents(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setErr(''); setMsg('');
|
||||
try {
|
||||
@@ -845,9 +866,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
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'}
|
||||
title="1 repo — mỗi sinh viên một folder"
|
||||
>
|
||||
{publishingGit ? 'Đang đẩy Git...' : '🔗 Đẩy lên Git'}
|
||||
{publishingGit ? 'Đang đẩy...' : '🔗 Git gộp (1 repo)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={publishingGitStudents || !gh.connected}
|
||||
onClick={publishToGitStudents}
|
||||
title="Mỗi sinh viên một repo GitHub riêng"
|
||||
>
|
||||
{publishingGitStudents ? 'Đang tạo repo...' : '👤 Git từng SV'}
|
||||
</button>
|
||||
{!gh.connected && (
|
||||
<span className="exam-submissions-toolbar-hint">
|
||||
@@ -858,7 +888,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</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.
|
||||
<strong>Git gộp</strong>: 1 repo, folder từng SV · <strong>Git từng SV</strong>: mỗi người 1 repo.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -875,6 +905,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<tr>
|
||||
<th>Sinh viên</th>
|
||||
<th>File</th>
|
||||
<th>Git</th>
|
||||
<th>Thời gian</th>
|
||||
<th style={{ textAlign: 'right' }}>Tải</th>
|
||||
</tr>
|
||||
@@ -887,6 +918,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{s.studentCode}</div>
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem' }}>{s.fileName}</td>
|
||||
<td style={{ fontSize: '0.8rem' }}>
|
||||
{s.gitPublishUrl ? (
|
||||
<a href={s.gitPublishUrl} target="_blank" rel="noreferrer">Repo</a>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{fmtTime(s.createdAt)}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => downloadSub(s.id, s.fileName)}>Tải</button>
|
||||
|
||||
186
management/src/components/GitHubReposPanel.tsx
Normal file
186
management/src/components/GitHubReposPanel.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { apiGitHubRepos, type GitHubRepoItem } from '../api';
|
||||
|
||||
export function GitHubReposPanel({ connected, canDeleteRepos = true }: { connected: boolean; canDeleteRepos?: boolean }) {
|
||||
const [repos, setRepos] = useState<GitHubRepoItem[]>([]);
|
||||
const [githubLogin, setGithubLogin] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!connected) {
|
||||
setRepos([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await apiGitHubRepos.list();
|
||||
setRepos(res.data || []);
|
||||
setGithubLogin(res.githubLogin || '');
|
||||
setSelected(new Set());
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Không tải repo');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connected]);
|
||||
|
||||
useEffect(() => { load().catch(console.error); }, [load]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selected.size === repos.length) setSelected(new Set());
|
||||
else setSelected(new Set(repos.map((r) => r.key)));
|
||||
};
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!canDeleteRepos) {
|
||||
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Xóa ${selected.size} repo trên GitHub? Hành động không hoàn tác.`)) return;
|
||||
setErr(''); setMsg('');
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await apiGitHubRepos.bulkDelete([...selected]);
|
||||
if (res.deleted > 0) {
|
||||
setMsg(res.message || `Đã xóa ${res.deleted} repo`);
|
||||
if (res.failures?.length) setErr(res.failures.join('; '));
|
||||
} else {
|
||||
setMsg('');
|
||||
setErr(res.failures?.join('; ') || 'Không xóa được repo nào — thử ngắt kết nối rồi kết nối lại GitHub.');
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Xóa thất bại');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOne = async (repo: GitHubRepoItem) => {
|
||||
if (!canDeleteRepos) {
|
||||
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Xóa repo "${repo.repoName}" trên GitHub?`)) return;
|
||||
setErr(''); setMsg('');
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await apiGitHubRepos.bulkDelete([repo.key]);
|
||||
if (res.deleted > 0) {
|
||||
setMsg(res.message || 'Đã xóa repo');
|
||||
} else {
|
||||
setMsg('');
|
||||
setErr(res.failures?.join('; ') || 'Không xóa được repo — thử ngắt kết nối rồi kết nối lại GitHub.');
|
||||
}
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Xóa thất bại');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!connected) return null;
|
||||
|
||||
return (
|
||||
<div className="github-repos-panel">
|
||||
<p className="exam-git-hint" style={{ marginTop: 0 }}>
|
||||
Toàn bộ repo trên tài khoản GitHub{githubLogin ? ` @${githubLogin}` : ''} — có thể chọn và xóa nhiều repo cùng lúc.
|
||||
</p>
|
||||
<div className="github-repos-toolbar">
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={loading} onClick={() => load()}>
|
||||
{loading ? 'Đang tải từ GitHub...' : 'Làm mới'}
|
||||
</button>
|
||||
{repos.length > 0 && (
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={toggleAll}>
|
||||
{selected.size === repos.length ? 'Bỏ chọn' : 'Chọn tất cả'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm learning-btn-danger"
|
||||
disabled={deleting || selected.size === 0 || !canDeleteRepos}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
{deleting ? 'Đang xóa...' : `Xóa đã chọn (${selected.size})`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{err && <div className="login-error">{err}</div>}
|
||||
{msg && <div className="login-success">{msg}</div>}
|
||||
{repos.length === 0 && !loading ? (
|
||||
<p className="exam-git-hint">Không có repo nào trên tài khoản GitHub (hoặc token thiếu quyền đọc repo).</p>
|
||||
) : (
|
||||
<div className="github-repos-table-wrap table-wrapper">
|
||||
<table className="data-table github-repos-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }} />
|
||||
<th>Repo</th>
|
||||
<th>Loại</th>
|
||||
<th>Phòng / SV</th>
|
||||
<th>Cập nhật</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map((r) => (
|
||||
<tr key={r.key}>
|
||||
<td>
|
||||
<input type="checkbox" checked={selected.has(r.key)} onChange={() => toggle(r.key)} />
|
||||
</td>
|
||||
<td>
|
||||
<a href={r.repoHtmlUrl} target="_blank" rel="noreferrer" className="github-repo-link">
|
||||
{r.repoName}
|
||||
</a>
|
||||
{r.private && <span className="badge badge-muted" style={{ marginLeft: 6 }}>private</span>}
|
||||
</td>
|
||||
<td>
|
||||
{r.publishMode === 'student' ? (
|
||||
<span className="badge badge-info">Từng SV</span>
|
||||
) : r.publishMode === 'room' ? (
|
||||
<span className="badge badge-muted">Gộp phòng</span>
|
||||
) : r.fromSimpleCare ? (
|
||||
<span className="badge badge-muted">Simple Care</span>
|
||||
) : (
|
||||
<span className="badge badge-muted">Khác</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.85rem' }}>
|
||||
{r.examRoomName && <div>{r.examRoomName}</div>}
|
||||
{r.studentLabel && <div className="text-muted">{r.studentLabel}</div>}
|
||||
{!r.examRoomName && !r.studentLabel && '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
{r.updatedAt ? new Date(r.updatedAt).toLocaleString('vi-VN') : new Date(r.createdAt).toLocaleString('vi-VN')}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" disabled={deleting || !canDeleteRepos} onClick={() => deleteOne(r)}>
|
||||
Xóa
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { apiGitHub } from '../api';
|
||||
export function useGitHubConnection() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [githubLogin, setGithubLogin] = useState('');
|
||||
const [canDeleteRepos, setCanDeleteRepos] = useState(false);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -13,10 +14,12 @@ export function useGitHubConnection() {
|
||||
const st = await apiGitHub.status();
|
||||
setConnected(!!st.connected);
|
||||
setGithubLogin(st.githubLogin || '');
|
||||
setCanDeleteRepos(!!st.canDeleteRepos);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setConnected(false);
|
||||
setGithubLogin('');
|
||||
setCanDeleteRepos(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -65,6 +68,7 @@ export function useGitHubConnection() {
|
||||
await apiGitHub.disconnect();
|
||||
setConnected(false);
|
||||
setGithubLogin('');
|
||||
setCanDeleteRepos(false);
|
||||
setMessage('Đã ngắt kết nối GitHub');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Ngắt kết nối thất bại');
|
||||
@@ -74,6 +78,7 @@ export function useGitHubConnection() {
|
||||
return {
|
||||
connected,
|
||||
githubLogin,
|
||||
canDeleteRepos,
|
||||
connecting,
|
||||
error,
|
||||
message,
|
||||
|
||||
@@ -4312,6 +4312,34 @@ input:checked + .slider:before {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.github-repos-panel {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.github-repos-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.github-repos-table-wrap {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.github-repo-link {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exam-git-branch-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user