Week 1-8: Spring Boot 学习计划完整项目
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
149
week7/backend/src/main/resources/static/css/style.css
Normal file
149
week7/backend/src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,149 @@
|
||||
/* ========== 基础重置 ========== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; padding: 20px; color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1100px; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ========== 头部 ========== */
|
||||
header {
|
||||
text-align: center; padding: 30px 20px 20px;
|
||||
}
|
||||
header h1 { font-size: 2em; color: #fff; margin-bottom: 6px; }
|
||||
.subtitle { color: rgba(255,255,255,0.75); font-size: 0.95em; }
|
||||
|
||||
.main-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 20px; background: #fff; border-radius: 12px 12px 0 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.main-header h1 { font-size: 1.4em; color: #333; margin-bottom: 0; }
|
||||
.main-header .subtitle { color: #999; margin-top: 2px; }
|
||||
|
||||
/* ========== 登录/注册卡片 ========== */
|
||||
.login-card {
|
||||
background: #fff; border-radius: 12px; padding: 36px 30px;
|
||||
max-width: 420px; margin: 0 auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||
}
|
||||
.login-card h2 { font-size: 1.3em; margin-bottom: 18px; color: #333; }
|
||||
.hint { color: #999; font-size: 0.85em; margin-bottom: 14px; }
|
||||
.hint.small { font-size: 0.8em; text-align: center; }
|
||||
.hint a { color: #667eea; text-decoration: none; }
|
||||
|
||||
/* ========== 表单 ========== */
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label { display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: 500; }
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px 12px; border: 1.5px solid #e0e0e0;
|
||||
border-radius: 8px; font-size: 0.95em; transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
|
||||
}
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
/* ========== 按钮 ========== */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 10px 20px; border: none; border-radius: 8px; font-size: 0.93em;
|
||||
cursor: pointer; transition: all 0.2s; text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: #667eea; color: #fff; }
|
||||
.btn-primary:hover { background: #5a6fd6; }
|
||||
.btn-outline { background: #fff; color: #667eea; border: 1.5px solid #667eea; }
|
||||
.btn-outline:hover { background: #f0f2ff; }
|
||||
.btn-danger { background: #e74c3c; color: #fff; padding: 5px 12px; font-size: 0.82em; }
|
||||
.btn-danger:hover { background: #c0392b; }
|
||||
.btn-edit { background: #2ecc71; color: #fff; padding: 5px 12px; font-size: 0.82em; margin-right: 6px; }
|
||||
.btn-edit:hover { background: #27ae60; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 0.82em; }
|
||||
.btn-block { display: block; width: 100%; }
|
||||
|
||||
/* ========== 工具栏 ========== */
|
||||
.toolbar {
|
||||
background: #fff; padding: 14px 20px; display: flex; gap: 10px;
|
||||
border-top: 1px solid #f0f0f0; align-items: center;
|
||||
}
|
||||
.toolbar input {
|
||||
flex: 1; padding: 9px 12px; border: 1.5px solid #e0e0e0; border-radius: 8px;
|
||||
font-size: 0.93em;
|
||||
}
|
||||
.toolbar input:focus { outline: none; border-color: #667eea; }
|
||||
|
||||
/* ========== 表格 ========== */
|
||||
.table-wrap {
|
||||
background: #fff; padding: 0 20px 20px; overflow-x: auto;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.93em; }
|
||||
thead th {
|
||||
text-align: left; padding: 12px 10px; border-bottom: 2px solid #667eea;
|
||||
color: #667eea; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 12px 10px; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
tbody tr:hover { background: #f8f9ff; }
|
||||
.loading, .empty { text-align: center; color: #999; padding: 40px 0 !important; }
|
||||
|
||||
/* ========== 成绩徽章 ========== */
|
||||
.badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 0.82em; font-weight: 600; color: #fff;
|
||||
}
|
||||
.badge-excellent { background: #2ecc71; }
|
||||
.badge-good { background: #3498db; }
|
||||
.badge-normal { background: #f39c12; }
|
||||
.badge-poor { background: #e74c3c; }
|
||||
|
||||
/* ========== 统计卡片 ========== */
|
||||
.stats {
|
||||
background: #fff; padding: 16px 20px; border-radius: 0 0 12px 12px;
|
||||
display: flex; gap: 14px; border-top: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1; text-align: center; padding: 14px 10px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.stat-value { display: block; font-size: 1.6em; font-weight: 700; color: #fff; }
|
||||
.stat-label { display: block; font-size: 0.78em; color: rgba(255,255,255,0.8); margin-top: 4px; }
|
||||
|
||||
/* ========== 错误消息 ========== */
|
||||
.error-msg { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; min-height: 1.2em; }
|
||||
.error-bar {
|
||||
background: #fff3cd; color: #856404; padding: 10px 20px; border-left: 4px solid #ffc107;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
/* ========== 弹窗 ========== */
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.45); display: flex; justify-content: center;
|
||||
align-items: center; z-index: 1000; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
.modal-overlay.show { opacity: 1; pointer-events: auto; }
|
||||
.modal {
|
||||
background: #fff; border-radius: 12px; padding: 28px 30px; width: 90%; max-width: 480px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal h2 { margin-bottom: 18px; font-size: 1.2em; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 600px) {
|
||||
body { padding: 10px; }
|
||||
.login-card { padding: 24px 18px; }
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
.main-header { flex-direction: column; gap: 10px; }
|
||||
.toolbar { flex-wrap: wrap; }
|
||||
.stats { flex-direction: column; }
|
||||
}
|
||||
105
week7/backend/src/main/resources/static/index.html
Normal file
105
week7/backend/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>学生管理系统 v2</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 登录面板 -->
|
||||
<div class="container" id="login-panel">
|
||||
<header>
|
||||
<h1>📚 学生管理系统 v2</h1>
|
||||
<p class="subtitle">Spring Security + JWT + Redis</p>
|
||||
</header>
|
||||
<div class="login-card">
|
||||
<h2>登录</h2>
|
||||
<p class="hint">默认账号:admin/123456 (管理员) 或 user/123456 (普通用户)</p>
|
||||
<div class="form-group">
|
||||
<input type="text" id="login-username" placeholder="用户名" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="login-password" placeholder="密码">
|
||||
</div>
|
||||
<div class="error-msg" id="login-error"></div>
|
||||
<button class="btn btn-primary btn-block" onclick="doLogin()">登录</button>
|
||||
<p class="hint small" style="margin-top:12px">
|
||||
没有账号?<a href="#" onclick="showRegister()">注册</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册面板 -->
|
||||
<div class="container" id="register-panel" style="display:none">
|
||||
<header><h1>📚 注册</h1></header>
|
||||
<div class="login-card">
|
||||
<h2>注册新账号</h2>
|
||||
<div class="form-group"><input type="text" id="reg-username" placeholder="用户名"></div>
|
||||
<div class="form-group"><input type="password" id="reg-password" placeholder="密码"></div>
|
||||
<div class="error-msg" id="reg-error"></div>
|
||||
<button class="btn btn-primary btn-block" onclick="doRegister()">注册</button>
|
||||
<p class="hint small" style="margin-top:12px"><a href="#" onclick="showLogin()">返回登录</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主面板 -->
|
||||
<div class="container" id="main-panel" style="display:none">
|
||||
<header class="main-header">
|
||||
<div>
|
||||
<h1>📚 学生管理系统 v2</h1>
|
||||
<span id="user-info"></span>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="logout()">退出</button>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<input type="text" id="keyword" placeholder="搜索..." onkeyup="if(event.key==='Enter')search()">
|
||||
<button class="btn btn-primary" id="btn-add" onclick="showForm(null)">+ 新增</button>
|
||||
<button class="btn btn-outline" onclick="loadStudents()">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="error-bar" id="error-bar" style="display:none"></div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>姓名</th><th>年龄</th><th>邮箱</th><th>成绩</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats-bar">
|
||||
<div class="stat-card"><span class="stat-value" id="stat-total">-</span><span class="stat-label">总数</span></div>
|
||||
<div class="stat-card"><span class="stat-value" id="stat-excellent">-</span><span class="stat-label">优秀(≥85)</span></div>
|
||||
<div class="stat-card"><span class="stat-value" id="stat-orm">-</span><span class="stat-label">ORM 引擎</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">新增学生</h2>
|
||||
<form id="student-form" onsubmit="submitForm(event)">
|
||||
<input type="hidden" id="form-id">
|
||||
<div class="form-group"><label>姓名 *</label><input type="text" id="form-name" required maxlength="20"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>年龄 *</label><input type="number" id="form-age" required min="1" max="150"></div>
|
||||
<div class="form-group"><label>成绩 *</label><input type="number" id="form-score" required min="0" max="100"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>邮箱 *</label><input type="email" id="form-email" required></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
210
week7/backend/src/main/resources/static/js/app.js
Normal file
210
week7/backend/src/main/resources/static/js/app.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const BASE = '/api';
|
||||
let token = localStorage.getItem('wk5_token') || '';
|
||||
let role = localStorage.getItem('wk5_role') || '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (token) { showMain(); loadStudents(); checkRoleUI(); }
|
||||
});
|
||||
|
||||
// ==================== 认证 ====================
|
||||
|
||||
async function doLogin() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const password = document.getElementById('login-password').value.trim();
|
||||
if (!username || !password) { showLoginError('请输入用户名和密码'); return; }
|
||||
|
||||
try {
|
||||
const resp = await fetch(BASE + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 200) {
|
||||
token = result.data.token;
|
||||
role = result.data.role;
|
||||
localStorage.setItem('wk5_token', token);
|
||||
localStorage.setItem('wk5_role', role);
|
||||
showMain(); loadStudents(); checkRoleUI();
|
||||
} else {
|
||||
showLoginError(result.message || '登录失败');
|
||||
}
|
||||
} catch (e) { showLoginError('连接失败: ' + e.message); }
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const username = document.getElementById('reg-username').value.trim();
|
||||
const password = document.getElementById('reg-password').value.trim();
|
||||
if (!username || !password) { document.getElementById('reg-error').textContent = '请输入用户名和密码'; return; }
|
||||
|
||||
try {
|
||||
const resp = await fetch(BASE + '/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 200) {
|
||||
token = result.data.token;
|
||||
role = result.data.role;
|
||||
localStorage.setItem('wk5_token', token);
|
||||
localStorage.setItem('wk5_role', role);
|
||||
showMain(); loadStudents(); checkRoleUI();
|
||||
} else {
|
||||
document.getElementById('reg-error').textContent = result.message || '注册失败';
|
||||
}
|
||||
} catch (e) { document.getElementById('reg-error').textContent = '连接失败'; }
|
||||
}
|
||||
|
||||
function showLoginError(msg) { document.getElementById('login-error').textContent = msg; }
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('login-panel').style.display = 'block';
|
||||
document.getElementById('register-panel').style.display = 'none';
|
||||
document.getElementById('main-panel').style.display = 'none';
|
||||
}
|
||||
function showRegister() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('register-panel').style.display = 'block';
|
||||
}
|
||||
function showMain() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('register-panel').style.display = 'none';
|
||||
document.getElementById('main-panel').style.display = 'block';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('wk5_token'); localStorage.removeItem('wk5_role');
|
||||
token = ''; role = ''; showLogin();
|
||||
}
|
||||
|
||||
/** 根据角色显示/隐藏操作按钮 */
|
||||
function checkRoleUI() {
|
||||
document.getElementById('user-info').textContent = '当前: ' + (role || '-') + ' | ';
|
||||
document.getElementById('btn-add').style.display = (role === 'ADMIN') ? '' : 'none';
|
||||
}
|
||||
|
||||
// ==================== 请求封装 ====================
|
||||
|
||||
async function api(url, options) {
|
||||
options = options || {};
|
||||
options.headers = options.headers || {};
|
||||
options.headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
const resp = await fetch(url, options);
|
||||
const result = await resp.json();
|
||||
|
||||
if (resp.status === 403) {
|
||||
showError('权限不足:需要 ADMIN 角色'); return null;
|
||||
}
|
||||
if ((resp.status === 401 || resp.status === 403) && result.code !== 200) {
|
||||
logout(); showError('认证失败,请重新登录'); return null;
|
||||
}
|
||||
if (result.extra && result.extra.orm) {
|
||||
document.getElementById('stat-orm').textContent = result.extra.orm;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const bar = document.getElementById('error-bar');
|
||||
bar.textContent = '⚠ ' + msg; bar.style.display = 'block';
|
||||
setTimeout(() => bar.style.display = 'none', 4000);
|
||||
}
|
||||
|
||||
// ==================== 数据 ====================
|
||||
|
||||
async function loadStudents(keyword) {
|
||||
const tbody = document.getElementById('table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading">加载中...</td></tr>';
|
||||
|
||||
let url = BASE + '/students';
|
||||
if (keyword) url += '?keyword=' + encodeURIComponent(keyword);
|
||||
|
||||
const result = await api(url);
|
||||
if (!result) { tbody.innerHTML = '<tr><td colspan="6" class="empty">加载失败</td></tr>'; return; }
|
||||
|
||||
const students = result.data;
|
||||
if (!students || students.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = students.map(s => {
|
||||
let cls = 'badge-poor';
|
||||
if (s.score >= 90) cls = 'badge-excellent';
|
||||
else if (s.score >= 80) cls = 'badge-good';
|
||||
else if (s.score >= 60) cls = 'badge-normal';
|
||||
const actions = role === 'ADMIN'
|
||||
? `<button class="btn-edit" onclick="showForm(${s.id})">编辑</button>
|
||||
<button class="btn-danger" onclick="deleteStudent(${s.id})">删除</button>`
|
||||
: '<span style="color:#ccc">--</span>';
|
||||
return `<tr>
|
||||
<td>${s.id}</td><td><strong>${escapeHtml(s.name)}</strong></td>
|
||||
<td>${s.age}</td><td>${escapeHtml(s.email)}</td>
|
||||
<td><span class="badge ${cls}">${s.score}</span></td>
|
||||
<td>${actions}</td></tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('stat-total').textContent = result.extra?.total ?? students.length;
|
||||
|
||||
const statResp = await api(BASE + '/students/stats/excellent?min=85');
|
||||
if (statResp) document.getElementById('stat-excellent').textContent = statResp.data;
|
||||
}
|
||||
|
||||
function search() { loadStudents(document.getElementById('keyword').value.trim() || undefined); }
|
||||
|
||||
// ==================== 表单 ====================
|
||||
|
||||
async function showForm(id) {
|
||||
document.getElementById('form-id').value = ''; document.getElementById('student-form').reset();
|
||||
if (id) {
|
||||
document.getElementById('modal-title').textContent = '编辑学生';
|
||||
const result = await api(BASE + '/students/' + id);
|
||||
if (!result) return;
|
||||
const s = result.data;
|
||||
document.getElementById('form-id').value = s.id;
|
||||
document.getElementById('form-name').value = s.name;
|
||||
document.getElementById('form-age').value = s.age;
|
||||
document.getElementById('form-email').value = s.email;
|
||||
document.getElementById('form-score').value = s.score;
|
||||
} else {
|
||||
document.getElementById('modal-title').textContent = '新增学生';
|
||||
}
|
||||
document.getElementById('modal-overlay').classList.add('show');
|
||||
}
|
||||
|
||||
async function submitForm(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('form-id').value;
|
||||
const body = {
|
||||
name: document.getElementById('form-name').value.trim(),
|
||||
age: parseInt(document.getElementById('form-age').value),
|
||||
email: document.getElementById('form-email').value.trim(),
|
||||
score: parseInt(document.getElementById('form-score').value)
|
||||
};
|
||||
|
||||
let result;
|
||||
if (id) {
|
||||
result = await api(BASE + '/students/' + id, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
} else {
|
||||
result = await api(BASE + '/students', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
if (result) { closeModal(); loadStudents(); }
|
||||
}
|
||||
|
||||
async function deleteStudent(id) {
|
||||
if (!confirm('确认删除?(MP 模式下为逻辑删除)')) return;
|
||||
const result = await api(BASE + '/students/' + id, { method: 'DELETE' });
|
||||
if (result) loadStudents();
|
||||
}
|
||||
|
||||
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); }
|
||||
document.addEventListener('click', e => { if (e.target.id === 'modal-overlay') closeModal(); });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && document.getElementById('login-panel').style.display !== 'none') doLogin();
|
||||
});
|
||||
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||
Reference in New Issue
Block a user