Files
student_manger/pages/students.vue
寒寒 05c33b1fe8 feat: initial commit with Nuxt 3 student management system
- Add Nuxt 3 + Prisma + SQLite full-stack setup
- Add student CRUD API with batch import/export
- Add stats dashboard with gender/class distribution
- Add target community settings feature
- Add Docker deployment support (Dockerfile + docker-compose)
- Add README with development and deployment instructions
2026-03-21 02:00:55 +08:00

476 lines
19 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-4">
<!-- 操作栏 -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex flex-wrap gap-4 items-center justify-between">
<div class="flex flex-wrap gap-4 items-center">
<input
v-model="searchForm.name"
type="text"
placeholder="搜索姓名..."
class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
<select v-model="searchForm.className" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">全部班级</option>
<option v-for="c in classList" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="searchForm.gender" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">全部性别</option>
<option value="男"></option>
<option value="女"></option>
</select>
<select v-model="searchForm.community" class="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none">
<option value="">全部小区</option>
<option v-for="c in targetCommunities" :key="c" :value="c">{{ c }}</option>
</select>
<button
@click="resetSearch"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition"
>
重置
</button>
</div>
<div class="flex gap-2">
<button
@click="exportStudents"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
📤 导出Excel
</button>
<button
@click="showImportModal = true"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
📥 导入Excel
</button>
<button
@click="openAddModal"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
新增学生
</button>
<button
@click="confirmClear"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
>
🗑 清空数据
</button>
</div>
</div>
</div>
<!-- 数据表格 -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">序号</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">班级</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">姓名</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">性别</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">出生日期</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">家庭住址</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">父亲姓名</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">父亲电话</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">母亲姓名</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-600">母亲电话</th>
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-600">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(student, index) in filteredStudents" :key="student.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-600">{{ index + 1 }}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ student.className }}</td>
<td class="px-4 py-3 text-sm text-gray-900">{{ student.name }}</td>
<td class="px-4 py-3 text-sm">
<span :class="student.gender === '男' ? 'text-blue-600' : 'text-pink-600'">{{ student.gender }}</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ student.birthday || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 max-w-xs truncate">{{ student.address || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ student.fatherName || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ student.fatherPhone || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ student.motherName || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ student.motherPhone || '-' }}</td>
<td class="px-4 py-3 text-center">
<button @click="editStudent(student)" class="text-blue-600 hover:text-blue-800 mr-3">编辑</button>
<button @click="confirmDelete(student)" class="text-red-600 hover:text-red-800">删除</button>
</td>
</tr>
<tr v-if="filteredStudents.length === 0">
<td colspan="11" class="px-4 py-8 text-center text-gray-500">暂无学生数据</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="showAddModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b">
<h3 class="text-lg font-semibold">{{ editingStudent ? '编辑学生' : '新增学生' }}</h3>
</div>
<form @submit.prevent="handleSubmit" class="p-6 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">班级 *</label>
<input v-model="formData.className" type="text" required class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">姓名 *</label>
<input v-model="formData.name" type="text" required class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">性别 *</label>
<select v-model="formData.gender" required class="w-full px-3 py-2 border rounded-lg">
<option value="">请选择</option>
<option value="男"></option>
<option value="女"></option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">出生日期</label>
<input v-model="formData.birthday" type="date" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">家庭住址</label>
<input v-model="formData.address" type="text" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">父亲姓名</label>
<input v-model="formData.fatherName" type="text" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">父亲电话</label>
<input v-model="formData.fatherPhone" type="tel" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">母亲姓名</label>
<input v-model="formData.motherName" type="text" class="w-full px-3 py-2 border rounded-lg" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">母亲电话</label>
<input v-model="formData.motherPhone" type="tel" class="w-full px-3 py-2 border rounded-lg" />
</div>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="closeModal" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg">取消</button>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-lg">{{ editingStudent ? '保存' : '添加' }}</button>
</div>
</form>
</div>
</div>
<!-- 导入弹窗 -->
<div v-if="showImportModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md m-4">
<div class="p-6 border-b">
<h3 class="text-lg font-semibold">导入Excel文件</h3>
</div>
<div class="p-6">
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition cursor-pointer"
@dragover.prevent
@drop.prevent="handleDrop"
@click="$refs.fileInput.click()"
>
<input ref="fileInput" type="file" accept=".xlsx,.xls" multiple class="hidden" @change="handleFileSelect" />
<div class="text-4xl mb-4">📁</div>
<p class="text-gray-600 mb-2">点击选择文件或将文件拖拽到此处</p>
<p class="text-sm text-gray-400">支持 .xlsx, .xls 格式</p>
</div>
<div v-if="selectedFiles.length" class="mt-4">
<p class="text-sm text-gray-600 mb-2">已选择 {{ selectedFiles.length }} 个文件</p>
<ul class="text-sm text-gray-500 space-y-1">
<li v-for="f in selectedFiles" :key="f.name"> {{ f.name }}</li>
</ul>
</div>
</div>
<div class="p-6 border-t flex justify-end gap-3">
<button @click="closeImportModal" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg">取消</button>
<button @click="handleImport" :disabled="!selectedFiles.length || importing" class="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
{{ importing ? '导入中...' : '开始导入' }}
</button>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div v-if="showDeleteModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm m-4 p-6">
<h3 class="text-lg font-semibold mb-2">确认删除</h3>
<p class="text-gray-600 mb-6">确定要删除学生 <strong>{{ deletingStudent?.name }}</strong> </p>
<div class="flex justify-end gap-3">
<button @click="showDeleteModal = false" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg">取消</button>
<button @click="handleDelete" class="px-4 py-2 bg-red-600 text-white rounded-lg">删除</button>
</div>
</div>
</div>
<!-- 清空确认弹窗 -->
<div v-if="showClearModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-sm m-4 p-6">
<h3 class="text-lg font-semibold mb-2">确认清空</h3>
<p class="text-gray-600 mb-6">确定要清空所有学生数据吗此操作不可恢复</p>
<div class="flex justify-end gap-3">
<button @click="showClearModal = false" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg">取消</button>
<button @click="handleClearAll" class="px-4 py-2 bg-red-600 text-white rounded-lg">清空</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="toast.show" :class="['fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white transition-all', toast.type === 'success' ? 'bg-green-600' : 'bg-red-600']">
{{ toast.message }}
</div>
</div>
</template>
<script setup lang="ts">
import * as XLSX from 'xlsx'
const { data: studentsData, refresh: refreshStudents } = await useFetch('/api/students')
const { data: statsData, refresh: refreshStats } = await useFetch('/api/stats')
const students = computed(() => studentsData.value || [])
const targetCommunities = computed(() => statsData.value?.targetCommunities || [])
const classList = computed(() => [...new Set(students.value.map((s: any) => s.className).filter(Boolean))].sort())
const searchForm = reactive({
name: '',
className: '',
gender: '',
community: ''
})
const filteredStudents = computed(() => {
return students.value.filter((s: any) => {
if (searchForm.name && !s.name?.includes(searchForm.name)) return false
if (searchForm.className && s.className !== searchForm.className) return false
if (searchForm.gender && s.gender !== searchForm.gender) return false
if (searchForm.community) {
if (!s.address || !s.address.includes(searchForm.community)) return false
}
return true
})
})
// 弹窗状态
const showAddModal = ref(false)
const showImportModal = ref(false)
const showDeleteModal = ref(false)
const showClearModal = ref(false)
const editingStudent = ref<any>(null)
const deletingStudent = ref<any>(null)
const selectedFiles = ref<any[]>([])
const importing = ref(false)
const toast = reactive({ show: false, message: '', type: 'success' })
const formData = reactive({
className: '',
name: '',
gender: '',
birthday: '',
address: '',
fatherName: '',
fatherPhone: '',
motherName: '',
motherPhone: ''
})
function showToast(message: string, type = 'success') {
toast.message = message
toast.type = type
toast.show = true
setTimeout(() => { toast.show = false }, 3000)
}
function resetSearch() {
searchForm.name = ''
searchForm.className = ''
searchForm.gender = ''
searchForm.community = ''
}
function openAddModal() {
editingStudent.value = null
Object.keys(formData).forEach(key => { (formData as any)[key] = '' })
showAddModal.value = true
}
function editStudent(student: any) {
editingStudent.value = student
Object.keys(formData).forEach(key => { (formData as any)[key] = student[key] || '' })
showAddModal.value = true
}
function closeModal() {
showAddModal.value = false
editingStudent.value = null
}
async function handleSubmit() {
try {
if (editingStudent.value) {
await $fetch(`/api/students/${editingStudent.value.id}`, {
method: 'PUT',
body: formData
})
showToast('更新成功')
} else {
await $fetch('/api/students', { method: 'POST', body: formData })
showToast('添加成功')
}
closeModal()
await refreshStudents()
await refreshStats()
} catch (e) {
showToast('操作失败', 'error')
}
}
function confirmDelete(student: any) {
deletingStudent.value = student
showDeleteModal.value = true
}
async function handleDelete() {
try {
await $fetch(`/api/students/${deletingStudent.value.id}`, { method: 'DELETE' })
showToast('删除成功')
showDeleteModal.value = false
deletingStudent.value = null
await refreshStudents()
await refreshStats()
} catch (e) {
showToast('删除失败', 'error')
}
}
function confirmClear() {
if (!students.value.length) {
showToast('暂无数据可清空', 'error')
return
}
showClearModal.value = true
}
async function handleClearAll() {
try {
await $fetch('/api/students/clear', { method: 'POST' })
showToast('已清空所有数据')
showClearModal.value = false
await refreshStudents()
await refreshStats()
} catch (e) {
showToast('清空失败', 'error')
}
}
function handleFileSelect(e: Event) {
selectedFiles.value = Array.from((e.target as HTMLInputElement).files || [])
}
function handleDrop(e: DragEvent) {
selectedFiles.value = Array.from(e.dataTransfer?.files || []).filter((f: any) => f.name.endsWith('.xlsx') || f.name.endsWith('.xls'))
}
function closeImportModal() {
showImportModal.value = false
selectedFiles.value = []
}
function formatDate(val: any) {
if (!val) return ''
try {
if (typeof val === 'number') {
const d = new Date(1900, 0, val - 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
} catch (e) {}
return String(val).trim()
}
async function handleImport() {
if (!selectedFiles.value.length) return
importing.value = true
let totalImported = 0
try {
for (const file of selectedFiles.value) {
const data = await readExcelFile(file)
const rows = data.slice(5).filter((row: any[]) => row[1] && row[2])
const students = rows.map((row: any[]) => ({
className: String(row[1] || '').trim(),
name: String(row[2] || '').trim(),
gender: String(row[3] || '').trim(),
birthday: formatDate(row[4]),
address: String(row[5] || '').trim(),
fatherName: String(row[6] || '').trim(),
fatherPhone: String(row[7] || '').trim(),
motherName: String(row[8] || '').trim(),
motherPhone: String(row[9] || '').trim()
}))
await $fetch('/api/students/import', {
method: 'POST',
body: { students }
})
totalImported += students.length
}
showToast(`导入完成:新增 ${totalImported}`)
closeImportModal()
await refreshStudents()
await refreshStats()
} catch (e) {
showToast('导入失败', 'error')
} finally {
importing.value = false
}
}
function readExcelFile(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = e => {
try {
const wb = XLSX.read(e.target?.result, { type: 'binary' })
const data = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1 })
resolve(data)
} catch (e) {
reject(e)
}
}
reader.onerror = reject
reader.readAsBinaryString(file)
})
}
function exportStudents() {
if (!students.value.length) {
showToast('暂无数据可导出', 'error')
return
}
const exportData = [
['班级', '姓名', '性别', '出生日期', '家庭住址', '父亲姓名', '父亲电话', '母亲姓名', '母亲电话'],
...students.value.map((s: any) => [
s.className || '', s.name || '', s.gender || '', s.birthday || '',
s.address || '', s.fatherName || '', s.fatherPhone || '',
s.motherName || '', s.motherPhone || ''
])
]
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet(exportData)
ws['!cols'] = [{ wch: 15 }, { wch: 10 }, { wch: 6 }, { wch: 12 }, { wch: 40 }, { wch: 10 }, { wch: 15 }, { wch: 10 }, { wch: 15 }]
XLSX.utils.book_append_sheet(wb, ws, '学生列表')
XLSX.writeFile(wb, `学生列表_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`)
showToast('导出成功')
}
</script>