feat: expand dashboard user workflows

This commit is contained in:
Codex
2026-03-19 10:48:45 +08:00
parent 897cee385d
commit a44f726c97
2 changed files with 319 additions and 6 deletions

View File

@@ -44,6 +44,10 @@ When the backend is running, open `/index.html` to use the lightweight dashboard
- request a login token - request a login token
- load project statistics - load project statistics
- query recent projects with keyword, type, level, and status filters - query recent projects with keyword, type, level, and status filters
- query the user directory with keyword, role, and status filters
- load the active teacher roster
- view and update the current user's profile details
- change the current user's password
- visualize status and level breakdowns - visualize status and level breakdowns
- view leader and advisor names directly in project rows - view leader and advisor names directly in project rows

View File

@@ -198,6 +198,7 @@
<span class="pill">Use satoken header</span> <span class="pill">Use satoken header</span>
<span class="pill">Supports quick login</span> <span class="pill">Supports quick login</span>
<span class="pill">Reads stats and project pages</span> <span class="pill">Reads stats and project pages</span>
<span class="pill">Includes user management</span>
</div> </div>
<div class="notice"> <div class="notice">
Seed accounts share the bundled demo password: Seed accounts share the bundled demo password:
@@ -290,6 +291,74 @@
<button class="btn-soft" type="button" id="resetBtn">Reset filters</button> <button class="btn-soft" type="button" id="resetBtn">Reset filters</button>
</div> </div>
</section> </section>
<section class="card">
<div class="eyebrow">People</div>
<h2>User management</h2>
<div class="field-grid">
<div class="field">
<label for="userKeyword">Keyword</label>
<input id="userKeyword" placeholder="Username, phone, email..." />
</div>
<div class="field">
<label for="userRole">Role</label>
<select id="userRole">
<option value="">All</option>
<option value="1">Student</option>
<option value="2">Teacher</option>
<option value="3">Administrator</option>
</select>
</div>
<div class="field">
<label for="userStatus">Status</label>
<select id="userStatus">
<option value="">All</option>
<option value="1">Active</option>
<option value="0">Disabled</option>
</select>
</div>
</div>
<div class="inline-actions" style="margin-top: 14px;">
<button class="btn" type="button" id="usersBtn">Load users</button>
<button class="btn-soft" type="button" id="teachersBtn">Load teachers</button>
</div>
</section>
<section class="card">
<div class="eyebrow">Profile</div>
<h2>Current account tools</h2>
<div class="field-grid">
<div class="field">
<label for="profileRealName">Real name</label>
<input id="profileRealName" placeholder="Display name" />
</div>
<div class="field">
<label for="profilePhone">Phone</label>
<input id="profilePhone" placeholder="13900000000" />
</div>
<div class="field">
<label for="profileEmail">Email</label>
<input id="profileEmail" placeholder="demo@example.com" />
</div>
<div class="field">
<label for="profileAvatar">Avatar URL</label>
<input id="profileAvatar" placeholder="https://..." />
</div>
<div class="field">
<label for="oldPassword">Current password</label>
<input id="oldPassword" type="password" placeholder="Current password" />
</div>
<div class="field">
<label for="newPassword">New password</label>
<input id="newPassword" type="password" placeholder="New password" />
</div>
</div>
<div class="inline-actions" style="margin-top: 14px;">
<button class="btn" type="button" id="profileBtn">Load profile</button>
<button class="btn-soft" type="button" id="saveProfileBtn">Save profile</button>
<button class="btn-soft" type="button" id="passwordBtn">Change password</button>
</div>
</section>
</aside> </aside>
<main class="content"> <main class="content">
@@ -343,6 +412,38 @@
</table> </table>
</div> </div>
</section> </section>
<section class="card">
<div class="eyebrow">Users</div>
<h2>User directory</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Contact</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="userTable">
<tr><td colspan="5">No user data loaded yet.</td></tr>
</tbody>
</table>
</div>
</section>
<section class="card">
<div class="eyebrow">Teachers</div>
<h2>Advisor roster</h2>
<div class="sub-grid">
<div class="list" id="teacherList"></div>
<div class="notice" id="sessionSummary">
Sign in to load user directory, teacher roster, and editable profile details.
</div>
</div>
</section>
</main> </main>
</div> </div>
</div> </div>
@@ -358,10 +459,22 @@
projectType: document.getElementById('projectType'), projectType: document.getElementById('projectType'),
projectLevel: document.getElementById('projectLevel'), projectLevel: document.getElementById('projectLevel'),
status: document.getElementById('status'), status: document.getElementById('status'),
userKeyword: document.getElementById('userKeyword'),
userRole: document.getElementById('userRole'),
userStatus: document.getElementById('userStatus'),
profileRealName: document.getElementById('profileRealName'),
profilePhone: document.getElementById('profilePhone'),
profileEmail: document.getElementById('profileEmail'),
profileAvatar: document.getElementById('profileAvatar'),
oldPassword: document.getElementById('oldPassword'),
newPassword: document.getElementById('newPassword'),
statusBox: document.getElementById('statusBox'), statusBox: document.getElementById('statusBox'),
projectTable: document.getElementById('projectTable'), projectTable: document.getElementById('projectTable'),
userTable: document.getElementById('userTable'),
statusList: document.getElementById('statusList'), statusList: document.getElementById('statusList'),
levelList: document.getElementById('levelList') levelList: document.getElementById('levelList'),
teacherList: document.getElementById('teacherList'),
sessionSummary: document.getElementById('sessionSummary')
}; };
const statRefs = { const statRefs = {
@@ -382,12 +495,24 @@
student001: { username: 'student001', password: 'admin123' } student001: { username: 'student001', password: 'admin123' }
}; };
const sessionState = {
userId: null,
username: '',
roleType: null,
realName: ''
};
refs.baseUrl.value = window.location.origin || 'http://localhost:8080'; refs.baseUrl.value = window.location.origin || 'http://localhost:8080';
applyPresetAccount(); applyPresetAccount();
document.getElementById('loginBtn').addEventListener('click', login); document.getElementById('loginBtn').addEventListener('click', login);
document.getElementById('statsBtn').addEventListener('click', loadStats); document.getElementById('statsBtn').addEventListener('click', loadStats);
document.getElementById('projectsBtn').addEventListener('click', loadProjects); document.getElementById('projectsBtn').addEventListener('click', loadProjects);
document.getElementById('usersBtn').addEventListener('click', loadUsers);
document.getElementById('teachersBtn').addEventListener('click', loadTeachers);
document.getElementById('profileBtn').addEventListener('click', loadProfile);
document.getElementById('saveProfileBtn').addEventListener('click', saveProfile);
document.getElementById('passwordBtn').addEventListener('click', changePassword);
refs.accountPreset.addEventListener('change', applyPresetAccount); refs.accountPreset.addEventListener('change', applyPresetAccount);
document.getElementById('resetBtn').addEventListener('click', () => { document.getElementById('resetBtn').addEventListener('click', () => {
refs.keyword.value = ''; refs.keyword.value = '';
@@ -412,10 +537,20 @@
if (payload.code !== 200) { if (payload.code !== 200) {
throw new Error(payload.message || 'Login failed.'); throw new Error(payload.message || 'Login failed.');
} }
sessionState.userId = payload.data.userId || null;
sessionState.username = payload.data.username || '';
sessionState.roleType = payload.data.roleType ?? null;
sessionState.realName = payload.data.realName || '';
refs.token.value = payload.data.token || ''; refs.token.value = payload.data.token || '';
updateSessionSummary();
setStatus('Login succeeded. Token stored in the satoken field.', 'success'); setStatus('Login succeeded. Token stored in the satoken field.', 'success');
await loadStats(); await Promise.allSettled([
await loadProjects(); loadStats(),
loadProjects(),
loadUsers(),
loadTeachers(),
loadProfile()
]);
} catch (error) { } catch (error) {
setStatus(error.message, 'error'); setStatus(error.message, 'error');
} }
@@ -484,13 +619,143 @@
} }
} }
async function apiFetch(path) { async function loadUsers() {
const headers = {}; setStatus('Loading user directory...');
const params = new URLSearchParams();
if (refs.userKeyword.value.trim()) params.set('keyword', refs.userKeyword.value.trim());
if (refs.userRole.value) params.set('roleType', refs.userRole.value);
if (refs.userStatus.value) params.set('status', refs.userStatus.value);
params.set('size', '8');
try {
const payload = await apiFetch('/api/users?' + params.toString());
const page = payload.data || {};
const records = page.records || [];
if (!records.length) {
refs.userTable.innerHTML = '<tr><td colspan="5">No matching users found.</td></tr>';
} else {
refs.userTable.innerHTML = records.map((item) => `
<tr>
<td>
<strong>${escapeHtml(item.realName || item.username || 'Unnamed user')}</strong><br />
<span>${escapeHtml(item.username || '-')}</span>
</td>
<td>${escapeHtml(item.roleTypeLabel || '-')}</td>
<td>${escapeHtml(item.statusLabel || '-')}</td>
<td>
<span>${escapeHtml(item.phone || '-')}</span><br />
<span>${escapeHtml(item.email || '-')}</span>
</td>
<td>${formatDateTime(item.updateTime || item.createTime)}</td>
</tr>
`).join('');
}
setStatus(`Loaded ${records.length} user record(s).`, 'success');
} catch (error) {
setStatus(error.message, 'error');
}
}
async function loadTeachers() {
setStatus('Loading teacher roster...');
try {
const payload = await apiFetch('/api/users/teachers');
const records = payload.data || [];
renderTeacherList(records);
setStatus(`Loaded ${records.length} teacher record(s).`, 'success');
} catch (error) {
setStatus(error.message, 'error');
}
}
async function loadProfile() {
if (!sessionState.userId) {
setStatus('Login first so the dashboard knows which profile to load.', 'error');
return;
}
setStatus('Loading current profile...');
try {
const payload = await apiFetch('/api/users/' + sessionState.userId);
const profile = payload.data || {};
refs.profileRealName.value = profile.realName || '';
refs.profilePhone.value = profile.phone || '';
refs.profileEmail.value = profile.email || '';
refs.profileAvatar.value = profile.avatar || '';
sessionState.realName = profile.realName || sessionState.realName;
sessionState.roleType = profile.roleType ?? sessionState.roleType;
updateSessionSummary();
setStatus('Profile loaded successfully.', 'success');
} catch (error) {
setStatus(error.message, 'error');
}
}
async function saveProfile() {
if (!sessionState.userId) {
setStatus('Login first so the dashboard can update your profile.', 'error');
return;
}
setStatus('Saving current profile...');
try {
const payload = await apiFetch('/api/users/profile', {
method: 'PUT',
body: JSON.stringify({
realName: refs.profileRealName.value.trim(),
phone: refs.profilePhone.value.trim(),
email: refs.profileEmail.value.trim(),
avatar: refs.profileAvatar.value.trim()
})
});
const profile = payload.data || {};
sessionState.realName = profile.realName || sessionState.realName;
updateSessionSummary();
setStatus('Profile saved successfully.', 'success');
await loadUsers();
await loadTeachers();
} catch (error) {
setStatus(error.message, 'error');
}
}
async function changePassword() {
if (!sessionState.userId) {
setStatus('Login first so the dashboard can change your password.', 'error');
return;
}
if (!refs.oldPassword.value || !refs.newPassword.value) {
setStatus('Enter both the current password and the new password.', 'error');
return;
}
setStatus('Changing password...');
try {
await apiFetch('/api/users/change-password', {
method: 'POST',
body: JSON.stringify({
oldPassword: refs.oldPassword.value,
newPassword: refs.newPassword.value
})
});
refs.oldPassword.value = '';
refs.newPassword.value = '';
setStatus('Password changed successfully.', 'success');
} catch (error) {
setStatus(error.message, 'error');
}
}
async function apiFetch(path, options = {}) {
const headers = { ...(options.headers || {}) };
const token = refs.token.value.trim(); const token = refs.token.value.trim();
if (token) { if (token) {
headers.satoken = token; headers.satoken = token;
} }
const response = await fetch(buildUrl(path), { headers }); if (options.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(buildUrl(path), { ...options, headers });
const payload = await response.json(); const payload = await response.json();
if (payload.code !== 200) { if (payload.code !== 200) {
throw new Error(payload.message || 'Request failed.'); throw new Error(payload.message || 'Request failed.');
@@ -511,6 +776,33 @@
`).join(''); `).join('');
} }
function renderTeacherList(items) {
if (!items.length) {
refs.teacherList.innerHTML = '<div class="list-item"><span>No teachers loaded</span><strong>-</strong></div>';
return;
}
refs.teacherList.innerHTML = items.map((item) => `
<div class="list-item">
<span>
<strong>${escapeHtml(item.realName || item.username || 'Teacher')}</strong><br />
${escapeHtml(item.email || item.phone || '-')}
</span>
<strong>${escapeHtml(item.roleTypeLabel || 'Teacher')}</strong>
</div>
`).join('');
}
function updateSessionSummary() {
const name = sessionState.realName || sessionState.username || 'Unknown user';
const role = roleLabel(sessionState.roleType);
refs.sessionSummary.innerHTML = `
Current session:
<br />${escapeHtml(name)}
<br />${escapeHtml(sessionState.username || '-')}
<br />Role: ${escapeHtml(role)}
`;
}
function buildUrl(path) { function buildUrl(path) {
const base = refs.baseUrl.value.trim().replace(/\/$/, ''); const base = refs.baseUrl.value.trim().replace(/\/$/, '');
return (base || 'http://localhost:8080') + path; return (base || 'http://localhost:8080') + path;
@@ -538,6 +830,23 @@
return parts.length ? parts.join('<br />') : '-'; return parts.length ? parts.join('<br />') : '-';
} }
function formatDateTime(value) {
if (!value) {
return '-';
}
const normalized = String(value).replace('T', ' ');
return escapeHtml(normalized);
}
function roleLabel(value) {
switch (Number(value)) {
case 1: return 'Student';
case 2: return 'Teacher';
case 3: return 'Administrator';
default: return 'Unknown';
}
}
function applyPresetAccount() { function applyPresetAccount() {
const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin; const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin;
refs.username.value = preset.username; refs.username.value = preset.username;