Files
student_manger/pages/index.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

347 lines
15 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-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>