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
This commit is contained in:
2026-03-21 02:00:55 +08:00
commit 05c33b1fe8
25 changed files with 15749 additions and 0 deletions

346
pages/index.vue Normal file
View File

@@ -0,0 +1,346 @@
<template>
<div class="space-y-6">
<!-- 顶部操作栏 -->
<div class="flex justify-end gap-3">
<button
@click="showSettingsModal = true"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition flex items-center gap-2"
>
设置观测小区
</button>
<button
@click="exportData"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition flex items-center gap-2"
>
📊 导出汇总数据
</button>
</div>
<!-- 数据概览汇总区域 -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b bg-gradient-to-r from-blue-500 to-blue-600 rounded-t-lg">
<h3 class="text-lg font-semibold text-white flex items-center gap-2">
📊 数据概览
</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div class="text-center p-4 bg-gray-50 rounded-lg">
<div class="text-3xl font-bold text-blue-600">{{ stats.total || 0 }}</div>
<div class="text-sm text-gray-500 mt-1">学生总数</div>
</div>
<div class="text-center p-4 bg-gray-50 rounded-lg">
<div class="text-3xl font-bold text-purple-600">{{ classList.length }}</div>
<div class="text-sm text-gray-500 mt-1">班级数量</div>
</div>
<div class="text-center p-4 bg-gray-50 rounded-lg">
<div class="text-3xl font-bold text-blue-600">{{ stats.genderStats?.male || 0 }}</div>
<div class="text-sm text-gray-500 mt-1">男生</div>
</div>
<div class="text-center p-4 bg-gray-50 rounded-lg">
<div class="text-3xl font-bold text-pink-600">{{ stats.genderStats?.female || 0 }}</div>
<div class="text-sm text-gray-500 mt-1">女生</div>
</div>
</div>
</div>
</div>
<!-- 设置弹窗 -->
<div v-if="showSettingsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="showSettingsModal = false">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg m-4">
<div class="p-6 border-b flex justify-between items-center">
<h3 class="text-lg font-semibold">设置观测小区</h3>
<button @click="showSettingsModal = false" class="text-gray-500 hover:text-gray-700 text-2xl">&times;</button>
</div>
<div class="p-6">
<p class="text-sm text-gray-600 mb-4">每行一个小区名称模糊匹配家庭住址时将识别这些小区</p>
<textarea
v-model="settingsText"
rows="12"
class="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none font-mono text-sm"
></textarea>
<div class="mt-4 flex justify-end gap-3">
<button
@click="showSettingsModal = false"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition"
>
取消
</button>
<button
@click="saveSettings"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
保存设置
</button>
</div>
</div>
</div>
</div>
<!-- 目标小区统计模块 -->
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg shadow-lg overflow-hidden">
<div class="bg-orange-500 text-white px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🏠</span>
<h3 class="text-lg font-semibold">目标小区统计</h3>
</div>
<span class="text-sm opacity-80">覆盖 {{ stats.addressStats?.length || 0 }} 个小区</span>
</div>
<div class="p-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
<div class="text-center">
<div class="text-4xl font-bold text-orange-600">{{ stats.targetCommunityTotal || 0 }}</div>
<div class="text-sm text-gray-600 mt-1">目标小区学生</div>
<div class="text-xs text-orange-500 mt-1">占总人数 {{ targetPercent }}%</div>
</div>
<div class="text-center">
<div class="text-4xl font-bold text-blue-600">{{ stats.targetCommunityMale || 0 }}</div>
<div class="text-sm text-gray-600 mt-1">目标小区男生</div>
<div class="text-xs text-blue-500 mt-1">占目标小区 {{ targetMalePercent }}%</div>
</div>
<div class="text-center">
<div class="text-4xl font-bold text-pink-600">{{ stats.targetCommunityFemale || 0 }}</div>
<div class="text-sm text-gray-600 mt-1">目标小区女生</div>
<div class="text-xs text-pink-500 mt-1">占目标小区 {{ targetFemalePercent }}%</div>
</div>
<div class="text-center">
<div class="text-4xl font-bold text-green-600">{{ stats.addressStats?.length || 0 }}</div>
<div class="text-sm text-gray-600 mt-1">覆盖小区数量</div>
<div class="text-xs text-green-500 mt-1"> {{ stats.targetCommunities?.length || 0 }} 个目标小区</div>
</div>
</div>
</div>
</div>
<!-- 班级统计 -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b flex items-center justify-between">
<h3 class="text-lg font-semibold">班级统计</h3>
<div class="flex items-center gap-4 text-sm">
<span class="text-gray-500">总计<span class="font-medium">{{ stats.total || 0 }}</span> </span>
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
{{ stats.genderStats?.male || 0 }}
</span>
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-pink-500"></span>
{{ stats.genderStats?.female || 0 }}
</span>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">#</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">班级</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">人数</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">男生</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">女生</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">占比</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(stat, index) in classDetailStats" :key="stat.name" class="hover:bg-gray-50">
<td class="px-6 py-3 text-sm text-gray-500">{{ index + 1 }}</td>
<td class="px-6 py-3 text-sm font-medium text-gray-900">{{ stat.name }}</td>
<td class="px-6 py-3 text-sm text-gray-900 text-right">{{ stat.count }}</td>
<td class="px-6 py-3 text-sm text-blue-600 text-right">{{ stat.male }}</td>
<td class="px-6 py-3 text-sm text-pink-600 text-right">{{ stat.female }}</td>
<td class="px-6 py-3 text-sm text-gray-500 text-center">{{ stat.percent }}%</td>
</tr>
<tr v-if="!classDetailStats.length">
<td colspan="6" class="px-6 py-8 text-center text-gray-400">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 年龄段分布 -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-semibold mb-4">年龄段分布</h3>
<div v-if="ageStats.length" class="flex items-center gap-4">
<div class="flex-1 space-y-3">
<div v-for="stat in ageStats" :key="stat.label" class="flex items-center gap-4">
<span class="w-16 text-sm text-gray-600">{{ stat.label }}</span>
<div class="flex-1 bg-gray-100 rounded-full h-8 overflow-hidden">
<div class="h-full bg-gradient-to-r from-blue-400 to-blue-600 rounded-full flex items-center justify-end pr-3 transition-all"
:style="{ width: `${(stat.count / maxAgeCount) * 100}%` }">
<span class="text-sm text-white font-medium">{{ stat.count }}</span>
</div>
</div>
<span class="w-12 text-sm text-gray-400 text-right">{{ ((stat.count / stats.total) * 100).toFixed(1) }}%</span>
</div>
</div>
</div>
<div v-else class="text-center text-gray-400 py-8">暂无数据</div>
</div>
<!-- 目标小区详细统计表格 -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="p-4 border-b flex items-center justify-between bg-orange-50">
<h3 class="text-lg font-semibold text-orange-800">目标小区住址详细统计</h3>
<span class="text-xs text-orange-600">当前观测小区{{ communityPreview }}</span>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-orange-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">排名</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">小区名称</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">人数</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">占目标小区%</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">占总数%</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">男生</th>
<th class="px-4 py-3 text-left text-sm font-semibold text-orange-800">女生</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="(stat, index) in addressDetailStats" :key="stat.name" class="hover:bg-orange-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">{{ stat.name }}</td>
<td class="px-4 py-3 text-sm text-gray-900">{{ stat.count }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ stat.targetPercent }}%</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ stat.totalPercent }}%</td>
<td class="px-4 py-3 text-sm text-blue-600">{{ stat.male }}</td>
<td class="px-4 py-3 text-sm text-pink-600">{{ stat.female }}</td>
</tr>
<tr v-if="!addressDetailStats.length">
<td colspan="7" class="px-4 py-8 text-center text-gray-400">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as XLSX from 'xlsx'
const { data: stats, refresh } = await useFetch('/api/stats')
const showSettingsModal = ref(false)
const settingsText = ref('')
// 计算属性
const classList = computed(() => {
if (!stats.value?.classStats) return []
return stats.value.classStats.map((s: any) => s.name)
})
const targetPercent = computed(() => {
if (!stats.value?.total) return 0
return ((stats.value.targetCommunityTotal / stats.value.total) * 100).toFixed(1)
})
const targetMalePercent = computed(() => {
if (!stats.value?.targetCommunityTotal) return 0
return ((stats.value.targetCommunityMale / stats.value.targetCommunityTotal) * 100).toFixed(1)
})
const targetFemalePercent = computed(() => {
if (!stats.value?.targetCommunityTotal) return 0
return ((stats.value.targetCommunityFemale / stats.value.targetCommunityTotal) * 100).toFixed(1)
})
const classDetailStats = computed(() => {
if (!stats.value?.classStats) return []
const total = stats.value.total || 0
return stats.value.classStats.map((s: any) => ({
...s,
percent: total > 0 ? ((s.count / total) * 100).toFixed(1) : 0
}))
})
const ageStats = computed(() => {
if (!stats.value?.ageGroups) return []
return Object.entries(stats.value.ageGroups)
.map(([label, count]) => ({ label, count }))
.filter((s: any) => s.count > 0)
})
const maxAgeCount = computed(() => {
return Math.max(...ageStats.value.map((s: any) => s.count), 1)
})
const addressDetailStats = computed(() => {
if (!stats.value?.addressStats) return []
const total = stats.value.targetCommunityTotal || 0
const allTotal = stats.value.total || 0
return stats.value.addressStats.map((s: any) => ({
...s,
targetPercent: total > 0 ? ((s.count / total) * 100).toFixed(1) : 0,
totalPercent: allTotal > 0 ? ((s.count / allTotal) * 100).toFixed(1) : 0
}))
})
const communityPreview = computed(() => {
const list = stats.value?.targetCommunities || []
return list.slice(0, 5).join('、') + (list.length > 5 ? '...' : '')
})
function openSettings() {
settingsText.value = (stats.value?.targetCommunities || []).join('\n')
showSettingsModal.value = true
}
async function saveSettings() {
const communities = settingsText.value
.split('\n')
.map((s: string) => s.trim())
.filter((s: string) => s.length > 0)
await $fetch('/api/settings/save', {
method: 'POST',
body: { key: 'targetCommunities', value: communities }
})
showSettingsModal.value = false
refresh()
}
// 初始化设置弹窗内容
watch(showSettingsModal, (val) => {
if (val) {
settingsText.value = (stats.value?.targetCommunities || []).join('\n')
}
})
// 导出数据
function exportData() {
const wb = XLSX.utils.book_new()
// 总体概览
const overviewData = [
['学生数据汇总报告'],
['生成时间', new Date().toLocaleString()],
[],
['指标', '数值'],
['学生总数', stats.value?.total || 0],
['男生', stats.value?.genderStats?.male || 0],
['女生', stats.value?.genderStats?.female || 0],
['目标小区学生', stats.value?.targetCommunityTotal || 0],
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(overviewData), '总体概览')
// 班级统计
const classData = [
['班级', '人数', '男生', '女生', '占比'],
...classDetailStats.value.map((s: any) => [s.name, s.count, s.male, s.female, s.percent + '%'])
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(classData), '班级统计')
// 目标小区统计
const communityData = [
['小区名称', '人数', '占目标小区%', '占总数%', '男生', '女生'],
...addressDetailStats.value.map((s: any) => [s.name, s.count, s.targetPercent + '%', s.totalPercent + '%', s.male, s.female])
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(communityData), '目标小区统计')
XLSX.writeFile(wb, `学生数据汇总_${new Date().toLocaleDateString().replace(/\//g, '-')}.xlsx`)
}
</script>

475
pages/students.vue Normal file
View File

@@ -0,0 +1,475 @@
<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>