Week 1-8: Spring Boot 学习计划完整项目

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:45:17 +08:00
commit f95aa18724
201 changed files with 18595 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
<!-- 学生列表页Day 3-6CRUD + 分页 + 搜索 + Loading + 空状态 + 错误处理-->
<template>
<div>
<NavBar />
<div class="page">
<!-- 工具栏Day 3 搜索 + Day 4 分页 -->
<div class="toolbar">
<input v-model="keyword" placeholder="搜索姓名..." @keyup.enter="search" />
<button class="btn-outline" @click="search">搜索</button>
<button class="btn-outline" @click="refresh">刷新</button>
<button v-if="auth.isAdmin" class="btn-add" @click="openForm()">+ 新增</button>
</div>
<!-- 错误提示Day 6 -->
<div class="error-bar" v-if="error">{{ error }}<button @click="refresh">重试</button></div>
<!-- Loading 状态Day 6 -->
<div class="loading" v-if="loading">加载中...</div>
<!-- 空状态Day 6 -->
<div class="empty" v-else-if="!loading && students.length === 0">
暂无数据
</div>
<!-- 表格 -->
<div class="table-wrap" v-else>
<table>
<thead>
<tr><th>ID</th><th>姓名</th><th>年龄</th><th>邮箱</th><th>成绩</th><th v-if="auth.isAdmin">操作</th></tr>
</thead>
<tbody>
<tr v-for="s in students" :key="s.id">
<td>{{ s.id }}</td>
<td><strong>{{ s.name }}</strong></td>
<td>{{ s.age }}</td>
<td>{{ s.email }}</td>
<td><span class="badge" :class="scoreClass(s.score)">{{ s.score }}</span></td>
<td v-if="auth.isAdmin">
<button class="btn-edit" @click="openForm(s)">编辑</button>
<button class="btn-del" @click="handleDelete(s.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页组件Day 4 -->
<Pagination :current="page" :totalPages="totalPages" @change="goPage" />
<!-- 统计卡片 -->
<div class="stats">
<div class="stat"><span class="v">{{ statsTotal }}</span><span class="l">总数</span></div>
<div class="stat"><span class="v">{{ statsExcellent }}</span><span class="l">优秀</span></div>
<div class="stat"><span class="v">{{ orm }}</span><span class="l">ORM</span></div>
</div>
<!-- 新增/编辑弹窗Day 3 + Day 5表单 + 提交 -->
<StudentForm
:visible="formVisible"
:student="editTarget"
:loading="formLoading"
@close="formVisible = false"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../stores/auth'
import { fetchStudents, fetchExcellent, createStudent, updateStudent, deleteStudent } from '../api/student'
import NavBar from '../components/NavBar.vue'
import Pagination from '../components/Pagination.vue'
import StudentForm from '../components/StudentForm.vue'
const auth = useAuthStore()
// 列表状态
const students = ref([])
const keyword = ref('')
const page = ref(0)
const size = ref(10)
const totalPages = ref(0)
const loading = ref(false)
const error = ref('')
const statsTotal = ref(0)
const statsExcellent = ref(0)
const orm = ref('--')
// 表单状态
const formVisible = ref(false)
const editTarget = ref(null)
const formLoading = ref(false)
function scoreClass(s) {
if (s >= 90) return 'badge-excellent'
if (s >= 80) return 'badge-good'
if (s >= 60) return 'badge-normal'
return 'badge-poor'
}
async function loadData() {
loading.value = true; error.value = ''
try {
const result = await fetchStudents(keyword.value || undefined, page.value, size.value)
students.value = result.data || []
statsTotal.value = result.extra?.total ?? students.value.length
if (result.extra?.pages != null) totalPages.value = result.extra.pages
else totalPages.value = Math.ceil((result.extra?.total ?? students.value.length) / size.value)
orm.value = result.extra?.orm ?? '--'
const exc = await fetchExcellent(85)
statsExcellent.value = exc.data ?? 0
} catch (e) {
error.value = e.message
students.value = []
} finally {
loading.value = false
}
}
function search() { page.value = 0; loadData() }
function refresh() { keyword.value = ''; page.value = 0; loadData() }
function goPage(p) { page.value = p; loadData() }
function openForm(student) {
editTarget.value = student || null
formVisible.value = true
}
async function handleFormSubmit(data) {
formLoading.value = true
try {
if (data.id) {
await updateStudent(data.id, data)
} else {
await createStudent(data)
}
formVisible.value = false
loadData()
} catch (e) {
alert(e.message)
} finally {
formLoading.value = false
}
}
async function handleDelete(id) {
if (!confirm('确认删除?')) return
try {
await deleteStudent(id)
loadData()
} catch (e) {
alert(e.message)
}
}
onMounted(loadData)
</script>
<style scoped>
.page { max-width: 1000px; margin: 30px auto; padding: 0 20px; }
/* 工具栏 */
.toolbar { display: flex; gap: 10px; margin-bottom: 16px; background: #fff; padding: 14px 18px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.toolbar input { flex: 1; padding: 9px 12px; border: 1.5px solid #ddd; border-radius: 8px; font-size: 0.93em; }
.toolbar input:focus { outline: none; border-color: #667eea; }
.btn-outline { padding: 9px 18px; border: 1.5px solid #667eea; background: #fff; color: #667eea; border-radius: 8px; cursor: pointer; font-size: 0.9em; }
.btn-outline:hover { background: #f0f2ff; }
.btn-add { padding: 9px 22px; background: #667eea; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.9em; }
.btn-add:hover { background: #5a6fd6; }
/* 错误/加载/空状态 */
.error-bar { background: #fff3cd; color: #856404; padding: 12px 18px; border-radius: 8px; margin-bottom: 14px; display: flex; justify-content: space-between; }
.error-bar button { background: none; border: none; color: #856404; text-decoration: underline; cursor: pointer; }
.loading, .empty { text-align: center; padding: 80px 0; color: #fff; font-size: 1.1em; }
/* 表格 */
.table-wrap { background: #fff; border-radius: 10px; padding: 0 20px 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.93em; }
thead th { text-align: left; padding: 14px 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; }
/* 成绩徽章 */
.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; }
/* 操作按钮 */
.btn-edit { padding: 4px 12px; background: #2ecc71; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82em; margin-right: 6px; }
.btn-edit:hover { background: #27ae60; }
.btn-del { padding: 4px 12px; background: #e74c3c; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.82em; }
.btn-del:hover { background: #c0392b; }
/* 统计 */
.stats { background: #fff; border-radius: 10px; padding: 16px 20px; display: flex; gap: 14px; margin-top: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.stat { flex: 1; text-align: center; padding: 14px 10px; border-radius: 8px; background: linear-gradient(135deg, #667eea, #764ba2); }
.v { display: block; font-size: 1.5em; font-weight: 700; color: #fff; }
.l { display: block; font-size: 0.78em; color: rgba(255,255,255,0.8); margin-top: 3px; }
@media (max-width: 600px) {
.toolbar { flex-wrap: wrap; }
.stats { flex-direction: column; }
}
</style>