forked from admin/innovation-platform
feat: expand dashboard user workflows
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user