Skip to content

Commit ed9f138

Browse files
committed
feat: phase 13 complete admin control panel
1 parent 254547a commit ed9f138

4 files changed

Lines changed: 328 additions & 4 deletions

File tree

client/package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"publisher": "ptlpoj",
44
"displayName": "PtLPOJ Local Judge",
55
"description": "Inner Team Python Online Judge Client",
6-
"version": "0.2.3",
6+
"version": "0.3.0",
77
"repository": {
88
"type": "git",
99
"url": "https://github.com/LynPtl/PtLPOJ.git"
@@ -64,6 +64,10 @@
6464
"command": "ptlpoj.setConfig",
6565
"title": "PtLPOJ: Set Server URL",
6666
"icon": "$(settings-gear)"
67+
},
68+
{
69+
"command": "ptlpoj.openAdminPanel",
70+
"title": "PtLPOJ: Open Admin Panel"
6771
}
6872
],
6973
"menus": {
@@ -147,6 +151,7 @@
147151
},
148152
"dependencies": {
149153
"axios": "^1.6.0",
154+
"form-data": "^4.0.5",
150155
"katex": "^0.16.33",
151156
"markdown-it": "^14.1.1",
152157
"markdown-it-katex": "^2.0.3"

client/src/adminView.ts

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
import * as vscode from 'vscode';
2+
import axios from 'axios';
3+
import * as fs from 'fs';
4+
5+
function getServerUrl(): string {
6+
return vscode.workspace.getConfiguration('ptlpoj').get<string>('serverUrl') || 'http://localhost:8080/api';
7+
}
8+
9+
export class AdminViewPanel {
10+
public static currentPanel: AdminViewPanel | undefined;
11+
private readonly _panel: vscode.WebviewPanel;
12+
private readonly _extensionUri: vscode.Uri;
13+
private _disposables: vscode.Disposable[] = [];
14+
private _context: vscode.ExtensionContext;
15+
private _adminToken: string;
16+
17+
public static async createOrShow(extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
18+
const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined;
19+
20+
if (AdminViewPanel.currentPanel) {
21+
AdminViewPanel.currentPanel._panel.reveal(column);
22+
return;
23+
}
24+
25+
const panel = vscode.window.createWebviewPanel(
26+
'ptlpojAdmin',
27+
'PtLPOJ Admin Control Panel',
28+
column || vscode.ViewColumn.One,
29+
{
30+
enableScripts: true,
31+
localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'resources')]
32+
}
33+
);
34+
35+
let token = await context.secrets.get('ptlpoj_admin_token') || '';
36+
AdminViewPanel.currentPanel = new AdminViewPanel(panel, extensionUri, context, token);
37+
}
38+
39+
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, context: vscode.ExtensionContext, token: string) {
40+
this._panel = panel;
41+
this._extensionUri = extensionUri;
42+
this._context = context;
43+
this._adminToken = token;
44+
45+
this._update();
46+
47+
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
48+
49+
this._panel.webview.onDidReceiveMessage(
50+
async message => {
51+
switch (message.type) {
52+
case 'fetchUsers':
53+
await this._handleFetchUsers();
54+
return;
55+
case 'addUser':
56+
await this._handleAddUser(message.email);
57+
return;
58+
case 'deleteUser':
59+
await this._handleDeleteUser(message.email);
60+
return;
61+
case 'uploadProblem':
62+
await this._handleUploadProblem(message.filePath);
63+
return;
64+
}
65+
},
66+
null,
67+
this._disposables
68+
);
69+
}
70+
71+
private async _handleFetchUsers() {
72+
try {
73+
const res = await axios.get(`${getServerUrl()}/admin/users`, {
74+
headers: { 'Authorization': `Bearer ${this._adminToken}` }
75+
});
76+
this._panel.webview.postMessage({ type: 'usersData', data: res.data });
77+
} catch (error: any) {
78+
vscode.window.showErrorMessage(`Fetch Users Failed: ${error.response?.data?.error || error.message}`);
79+
}
80+
}
81+
82+
private async _handleAddUser(email: string) {
83+
try {
84+
await axios.post(`${getServerUrl()}/admin/users`, { email: email }, {
85+
headers: { 'Authorization': `Bearer ${this._adminToken}` }
86+
});
87+
vscode.window.showInformationMessage(`User ${email} added to whitelist!`);
88+
this._handleFetchUsers(); // Refresh
89+
} catch (error: any) {
90+
vscode.window.showErrorMessage(`Add User Failed: ${error.response?.data?.error || error.message}`);
91+
}
92+
}
93+
94+
private async _handleDeleteUser(email: string) {
95+
try {
96+
await axios.delete(`${getServerUrl()}/admin/users?email=${encodeURIComponent(email)}`, {
97+
headers: { 'Authorization': `Bearer ${this._adminToken}` }
98+
});
99+
vscode.window.showInformationMessage(`User ${email} removed.`);
100+
this._handleFetchUsers(); // Refresh
101+
} catch (error: any) {
102+
vscode.window.showErrorMessage(`Delete User Failed: ${error.response?.data?.error || error.message}`);
103+
}
104+
}
105+
106+
private async _handleUploadProblem(filePath: string) {
107+
try {
108+
const fileStream = fs.createReadStream(filePath);
109+
const formData = new (require('form-data'))();
110+
formData.append('python_file', fileStream);
111+
112+
vscode.window.showInformationMessage('AST Parsing and Uploading Problem...');
113+
114+
const res = await axios.post(`${getServerUrl()}/admin/problems`, formData, {
115+
headers: {
116+
...formData.getHeaders(),
117+
'Authorization': `Bearer ${this._adminToken}`
118+
}
119+
});
120+
vscode.window.showInformationMessage(`✅ ${res.data.message}`);
121+
} catch (error: any) {
122+
vscode.window.showErrorMessage(`Upload Failed: ${error.response?.data?.error || error.message}`);
123+
}
124+
}
125+
126+
public dispose() {
127+
AdminViewPanel.currentPanel = undefined;
128+
this._panel.dispose();
129+
while (this._disposables.length) {
130+
const x = this._disposables.pop();
131+
if (x) {
132+
x.dispose();
133+
}
134+
}
135+
}
136+
137+
private _update() {
138+
const webview = this._panel.webview;
139+
this._panel.webview.html = this._getHtmlForWebview(webview);
140+
}
141+
142+
private _getHtmlForWebview(webview: vscode.Webview): string {
143+
return `<!DOCTYPE html>
144+
<html lang="en">
145+
<head>
146+
<meta charset="UTF-8">
147+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
148+
<title>PtLPOJ Admin</title>
149+
<style>
150+
body { font-family: var(--vscode-font-family); padding: 20px; color: var(--vscode-editor-foreground); background-color: var(--vscode-editor-background); }
151+
.tab { overflow: hidden; border-bottom: 1px solid var(--vscode-panel-border); margin-bottom: 15px; }
152+
.tab button { background-color: inherit; float: left; border: none; outline: none; cursor: pointer; padding: 10px 20px; color: var(--vscode-editor-foreground); transition: 0.3s; font-size: 14px; font-weight: bold; }
153+
.tab button:hover { background-color: var(--vscode-list-hoverBackground); }
154+
.tab button.active { border-bottom: 2px solid var(--vscode-button-background); color: var(--vscode-button-background); }
155+
.tabcontent { display: none; padding: 10px 0; animation: fadeEffect 0.5s; }
156+
@keyframes fadeEffect { from {opacity: 0;} to {opacity: 1;} }
157+
158+
/* Tables */
159+
table { width: 100%; border-collapse: collapse; margin-top: 15px; }
160+
th, td { border-bottom: 1px solid var(--vscode-panel-border); padding: 10px; text-align: left; }
161+
th { font-weight: bold; color: var(--vscode-editor-foreground); opacity: 0.8; }
162+
163+
/* Forms & Buttons */
164+
input[type="text"], input[type="file"] { width: 100%; padding: 8px; margin: 8px 0; box-sizing: border-box; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border); border-radius: 4px; }
165+
button.primary { background-color: var(--vscode-button-background); color: var(--vscode-button-foreground); border: none; padding: 10px 15px; cursor: pointer; border-radius: 4px; font-weight: bold; margin-top: 10px; }
166+
button.primary:hover { background-color: var(--vscode-button-hoverBackground); }
167+
button.danger { background-color: var(--vscode-errorForeground); color: white; border: none; padding: 5px 10px; cursor: pointer; border-radius: 3px; font-size: 12px; }
168+
button.danger:hover { opacity: 0.8; }
169+
170+
.card { background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); margin-top: 10px; }
171+
.help-text { font-size: 12px; color: var(--vscode-descriptionForeground); margin-top: 5px; display: block; }
172+
</style>
173+
</head>
174+
<body>
175+
<h2>👑 PtLPOJ Admin Control Panel</h2>
176+
177+
<div class="tab">
178+
<button class="tablinks active" onclick="openTab(event, 'Whitelist')">👥 Whitelist Manager</button>
179+
<button class="tablinks" onclick="openTab(event, 'Problems')">📚 Problem Uploader</button>
180+
</div>
181+
182+
<div id="Whitelist" class="tabcontent" style="display:block;">
183+
<div class="card">
184+
<h3>Add New User</h3>
185+
<div style="display: flex; gap: 10px; align-items: center;">
186+
<input type="text" id="newEmail" placeholder="student@example.com" style="flex: 1;" />
187+
<button class="primary" style="margin-top: 0;" onclick="addUser()">Add to Whitelist</button>
188+
</div>
189+
</div>
190+
191+
<h3>Authorized Users</h3>
192+
<table>
193+
<thead>
194+
<tr><th>Email</th><th>Role</th><th>Action</th></tr>
195+
</thead>
196+
<tbody id="usersTableBody">
197+
<tr><td colspan="3">Loading...</td></tr>
198+
</tbody>
199+
</table>
200+
</div>
201+
202+
<div id="Problems" class="tabcontent">
203+
<div class="card">
204+
<h3>🚀 Smart Python Problem Uploader</h3>
205+
<p style="opacity: 0.8; font-size: 13px;">Our system automatically utilizes AST Parsing to deeply analyze the native Python code structure you upload.</p>
206+
<p style="opacity: 0.8; font-size: 13px;"><strong>Rule:</strong> The file must contain a function <code>def f(...):</code> and rigorous <code>doctests</code>. We will automatically generate the scaffolds and hidden tests.</p>
207+
208+
<div style="margin-top: 20px;">
209+
<label for="problemFile" style="font-weight: bold;">Select .py Source File:</label>
210+
<input type="file" id="problemFile" accept=".py" />
211+
<span class="help-text">Example: "01_Add_Two_Numbers.py"</span>
212+
</div>
213+
214+
<button class="primary" style="margin-top: 20px; width: 100%;" onclick="uploadProblem()">Execute Smart Parse & Upload</button>
215+
</div>
216+
</div>
217+
218+
<script>
219+
const vscode = acquireVsCodeApi();
220+
221+
function openTab(evt, tabName) {
222+
var i, tabcontent, tablinks;
223+
tabcontent = document.getElementsByClassName("tabcontent");
224+
for (i = 0; i < tabcontent.length; i++) {
225+
tabcontent[i].style.display = "none";
226+
}
227+
tablinks = document.getElementsByClassName("tablinks");
228+
for (i = 0; i < tablinks.length; i++) {
229+
tablinks[i].className = tablinks[i].className.replace(" active", "");
230+
}
231+
document.getElementById(tabName).style.display = "block";
232+
evt.currentTarget.className += " active";
233+
234+
if (tabName === 'Whitelist') {
235+
vscode.postMessage({ type: 'fetchUsers' });
236+
}
237+
}
238+
239+
// Initial fetch
240+
vscode.postMessage({ type: 'fetchUsers' });
241+
242+
window.addEventListener('message', event => {
243+
const message = event.data;
244+
if (message.type === 'usersData') {
245+
const tbody = document.getElementById('usersTableBody');
246+
tbody.innerHTML = '';
247+
if (message.data.length === 0) {
248+
tbody.innerHTML = '<tr><td colspan="3">No users found.</td></tr>';
249+
return;
250+
}
251+
message.data.forEach(user => {
252+
const tr = document.createElement('tr');
253+
tr.innerHTML = \`
254+
<td>\${user.email}</td>
255+
<td><span style="background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); padding: 2px 6px; border-radius: 10px; font-size: 11px;">\${user.role}</span></td>
256+
<td><button class="danger" onclick="deleteUser('\${user.email}')">Remove</button></td>
257+
\`;
258+
tbody.appendChild(tr);
259+
});
260+
}
261+
});
262+
263+
function addUser() {
264+
const emailInput = document.getElementById('newEmail');
265+
const email = emailInput.value.trim();
266+
if (email) {
267+
vscode.postMessage({ type: 'addUser', email });
268+
emailInput.value = '';
269+
}
270+
}
271+
272+
function deleteUser(email) {
273+
if (confirm('Are you sure you want to remove ' + email + '?')) {
274+
vscode.postMessage({ type: 'deleteUser', email });
275+
}
276+
}
277+
278+
function uploadProblem() {
279+
const fileInput = document.getElementById('problemFile');
280+
if (fileInput.files.length > 0) {
281+
// Since Webview cannot send raw files easily over postMessage without base64 bloat,
282+
// we send the absolute path back to the extension host to read it directly.
283+
// However, file.path is available in electron browsers for absolute path!
284+
const file = fileInput.files[0];
285+
if (file.path) {
286+
vscode.postMessage({ type: 'uploadProblem', filePath: file.path });
287+
} else {
288+
alert("Failed to resolve file path.");
289+
}
290+
} else {
291+
alert('Please select a .py file first.');
292+
}
293+
}
294+
</script>
295+
</body>
296+
</html>`;
297+
}
298+
}

client/src/extension.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PtLpoCodeLensProvider } from './codeLensProvider';
77
import { ProblemViewPanel } from './problemView';
88
import { DashboardViewPanel } from './dashboardView';
99
import { LoginViewPanel } from './loginView';
10+
import { AdminViewPanel } from './adminView';
1011
import * as http from 'http';
1112

1213
const TOKEN_KEY = 'ptlpoj_jwt_token';
@@ -231,6 +232,24 @@ export function activate(context: vscode.ExtensionContext) {
231232
}
232233
});
233234

235+
const ADMIN_TOKEN_KEY = 'ptlpoj_admin_token';
236+
const openAdminPanelCommand = vscode.commands.registerCommand('ptlpoj.openAdminPanel', async () => {
237+
let adminToken = await context.secrets.get(ADMIN_TOKEN_KEY);
238+
if (!adminToken) {
239+
adminToken = await vscode.window.showInputBox({
240+
prompt: 'Enter Admin Token to access Control Panel',
241+
password: true,
242+
ignoreFocusOut: true
243+
});
244+
if (adminToken) {
245+
await context.secrets.store(ADMIN_TOKEN_KEY, adminToken);
246+
} else {
247+
return; // User cancelled
248+
}
249+
}
250+
AdminViewPanel.createOrShow(context.extensionUri, context);
251+
});
252+
234253
context.subscriptions.push(
235254
loginCommand,
236255
logoutCommand,
@@ -244,7 +263,8 @@ export function activate(context: vscode.ExtensionContext) {
244263
runTestCommand,
245264
setFilterSortCommand,
246265
diffSubmissionCommand,
247-
searchProblemsCommand
266+
searchProblemsCommand,
267+
openAdminPanelCommand
248268
);
249269
}
250270

0 commit comments

Comments
 (0)