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:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
*.md
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DATABASE_URL="file:./prisma/dev.db"
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
.output/
|
||||||
|
.nuxt/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.production
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests/
|
||||||
|
__tests__/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
54
Dockerfile
Normal file
54
Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 多阶段构建
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装构建依赖
|
||||||
|
RUN apk add --no-cache openssl python3 make g++
|
||||||
|
|
||||||
|
# 先复制 Prisma schema
|
||||||
|
COPY prisma ./prisma
|
||||||
|
|
||||||
|
# 复制 package 文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# 生成 Prisma Client
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产镜像
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装运行时依赖
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
# 创建数据目录
|
||||||
|
RUN mkdir -p /app/data && chmod 777 /app/data
|
||||||
|
|
||||||
|
# 复制 node_modules
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# 复制 Prisma
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# 复制 package.json
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/.output ./
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
CMD ["node", "server/index.mjs"]
|
||||||
115
README.md
Normal file
115
README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 学生数据管理系统
|
||||||
|
|
||||||
|
基于 Nuxt 3 + SQLite + Prisma 的全栈学生数据管理系统,支持 Docker 部署。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 📊 数据概览:班级统计、年龄段分布、目标小区统计
|
||||||
|
- 👨🎓 学生管理:增删改查、批量导入导出
|
||||||
|
- 🏠 目标小区分析:可配置观测小区列表
|
||||||
|
- 📤 数据导出:支持 Excel 格式导出
|
||||||
|
- 🐳 Docker 部署:开箱即用
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 初始化数据库
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:3000
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
#### 使用 Docker Compose(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用 Dockerfile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t student-app .
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -p 3000:3000 student-app
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据持久化
|
||||||
|
|
||||||
|
数据库文件存储在 Docker 匿名卷中,容器删除后数据保留。
|
||||||
|
|
||||||
|
如需手动备份数据库:
|
||||||
|
```bash
|
||||||
|
docker compose cp student-app:/app/data/student.db ./backup.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── server/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── students/ # 学生 CRUD API
|
||||||
|
│ │ ├── stats/ # 统计数据 API
|
||||||
|
│ │ └── settings/ # 设置 API
|
||||||
|
│ └── utils/
|
||||||
|
│ └── prisma.ts # Prisma 客户端
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.vue # 数据概览页
|
||||||
|
│ └── students.vue # 学生列表页
|
||||||
|
├── layouts/
|
||||||
|
│ └── default.vue # 默认布局
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma # 数据库模型
|
||||||
|
├── assets/
|
||||||
|
│ └── css/
|
||||||
|
│ └── main.css # 全局样式
|
||||||
|
├── Dockerfile # Docker 构建文件
|
||||||
|
├── docker-compose.yml # Docker Compose 配置
|
||||||
|
└── .env # 环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端**:Nuxt 3, Vue 3, Tailwind CSS
|
||||||
|
- **后端**:Nuxt Server Routes, Prisma ORM
|
||||||
|
- **数据库**:SQLite
|
||||||
|
- **部署**:Docker, Docker Compose
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | /api/students | 获取所有学生 |
|
||||||
|
| POST | /api/students | 添加学生 |
|
||||||
|
| PUT | /api/students/:id | 更新学生 |
|
||||||
|
| DELETE | /api/students/:id | 删除学生 |
|
||||||
|
| POST | /api/students/import | 批量导入 |
|
||||||
|
| POST | /api/students/clear | 清空数据 |
|
||||||
|
| GET | /api/stats | 获取统计数据 |
|
||||||
|
| GET | /api/settings | 获取设置 |
|
||||||
|
| POST | /api/settings/save | 保存设置 |
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| DATABASE_URL | SQLite 数据库路径 | file:./data/student.db |
|
||||||
|
| NODE_ENV | 运行环境 | development |
|
||||||
5
app.vue
Normal file
5
app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
30
assets/css/main.css
Normal file
30
assets/css/main.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-medium transition;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-blue-600 text-white hover:bg-blue-700;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply bg-red-600 text-white hover:bg-red-700;
|
||||||
|
}
|
||||||
|
.btn-success {
|
||||||
|
@apply bg-green-600 text-white hover:bg-green-700;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-lg shadow p-6;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: student-app
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
# 持久化数据库文件
|
||||||
|
- student-data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=file:./data/student.db
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
student-data:
|
||||||
34
layouts/default.vue
Normal file
34
layouts/default.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100">
|
||||||
|
<header class="bg-white shadow">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold text-gray-800">📚 学生数据管理系统</h1>
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="px-4 py-2 rounded-lg hover:bg-gray-100"
|
||||||
|
:class="$route.path === '/' ? 'bg-blue-100 text-blue-600' : 'text-gray-600'"
|
||||||
|
>
|
||||||
|
📊 数据概览
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/students"
|
||||||
|
class="px-4 py-2 rounded-lg hover:bg-gray-100"
|
||||||
|
:class="$route.path === '/students' ? 'bg-blue-100 text-blue-600' : 'text-gray-600'"
|
||||||
|
>
|
||||||
|
👨🎓 学生列表
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
nuxt.config.ts
Normal file
35
nuxt.config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
devtools: { enabled: true },
|
||||||
|
nitro: {
|
||||||
|
preset: "node-server",
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: ["@nuxtjs/tailwindcss"],
|
||||||
|
|
||||||
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
baseURL: "/",
|
||||||
|
cdnURL: "/",
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
title: "学生数据管理系统",
|
||||||
|
meta: [
|
||||||
|
{ charset: "utf-8" },
|
||||||
|
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||||
|
{ name: "description", content: "幼儿园学生数据管理系统" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
compatibilityDate: "2024-01-01",
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: process.env.API_BASE || "/api",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
14290
package-lock.json
generated
Normal file
14290
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "student-data-system",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@libsql/client": "^0.6.0",
|
||||||
|
"@prisma/client": "^5.10.0",
|
||||||
|
"nuxt": "^3.10.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/devtools": "^1.0.0",
|
||||||
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"prisma": "^5.10.0",
|
||||||
|
"tailwindcss": "^3.4.19"
|
||||||
|
}
|
||||||
|
}
|
||||||
346
pages/index.vue
Normal file
346
pages/index.vue
Normal 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">×</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
475
pages/students.vue
Normal 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>
|
||||||
30
prisma/schema.prisma
Normal file
30
prisma/schema.prisma
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Student {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
className String
|
||||||
|
name String
|
||||||
|
gender String
|
||||||
|
birthday String?
|
||||||
|
address String?
|
||||||
|
fatherName String?
|
||||||
|
fatherPhone String?
|
||||||
|
motherName String?
|
||||||
|
motherPhone String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Settings {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
10
server/api/settings/index.get.ts
Normal file
10
server/api/settings/index.get.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const settings = await prisma.settings.findMany()
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
settings.forEach(s => {
|
||||||
|
result[s.key] = s.value
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
})
|
||||||
21
server/api/settings/save.post.ts
Normal file
21
server/api/settings/save.post.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (body.key === 'targetCommunities' && Array.isArray(body.value)) {
|
||||||
|
await prisma.settings.upsert({
|
||||||
|
where: { key: body.key },
|
||||||
|
create: { key: body.key, value: JSON.stringify(body.value) },
|
||||||
|
update: { value: JSON.stringify(body.value) }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.settings.upsert({
|
||||||
|
where: { key: body.key },
|
||||||
|
create: { key: body.key, value: body.value },
|
||||||
|
update: { value: body.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
87
server/api/stats/index.get.ts
Normal file
87
server/api/stats/index.get.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { prisma, defaultCommunities } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const students = await prisma.student.findMany()
|
||||||
|
|
||||||
|
// 获取设置的目标小区
|
||||||
|
const settings = await prisma.settings.findMany({
|
||||||
|
where: { key: 'targetCommunities' }
|
||||||
|
})
|
||||||
|
const targetCommunities = settings.length > 0
|
||||||
|
? JSON.parse(settings[0].value)
|
||||||
|
: defaultCommunities
|
||||||
|
|
||||||
|
// 基础统计
|
||||||
|
const total = students.length
|
||||||
|
const genderStats = {
|
||||||
|
male: students.filter(s => s.gender === '男').length,
|
||||||
|
female: students.filter(s => s.gender === '女').length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 班级统计
|
||||||
|
const classStats: Record<string, { count: number; male: number; female: number }> = {}
|
||||||
|
students.forEach(s => {
|
||||||
|
if (!classStats[s.className]) {
|
||||||
|
classStats[s.className] = { count: 0, male: 0, female: 0 }
|
||||||
|
}
|
||||||
|
classStats[s.className].count++
|
||||||
|
if (s.gender === '男') classStats[s.className].male++
|
||||||
|
else if (s.gender === '女') classStats[s.className].female++
|
||||||
|
})
|
||||||
|
|
||||||
|
// 年龄段统计
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const ageGroups = {
|
||||||
|
'3岁以下': 0,
|
||||||
|
'3-4岁': 0,
|
||||||
|
'4-5岁': 0,
|
||||||
|
'5-6岁': 0,
|
||||||
|
'6岁以上': 0
|
||||||
|
}
|
||||||
|
students.forEach(s => {
|
||||||
|
if (!s.birthday) return
|
||||||
|
const birthYear = parseInt(s.birthday.substring(0, 4))
|
||||||
|
if (isNaN(birthYear)) return
|
||||||
|
const age = currentYear - birthYear
|
||||||
|
if (age < 3) ageGroups['3岁以下']++
|
||||||
|
else if (age < 4) ageGroups['3-4岁']++
|
||||||
|
else if (age < 5) ageGroups['4-5岁']++
|
||||||
|
else if (age <= 6) ageGroups['5-6岁']++
|
||||||
|
else ageGroups['6岁以上']++
|
||||||
|
})
|
||||||
|
|
||||||
|
// 目标小区统计
|
||||||
|
const addressStats: Record<string, { count: number; male: number; female: number }> = {}
|
||||||
|
targetCommunities.forEach(name => {
|
||||||
|
addressStats[name] = { count: 0, male: 0, female: 0 }
|
||||||
|
})
|
||||||
|
students.forEach(s => {
|
||||||
|
if (!s.address) return
|
||||||
|
targetCommunities.forEach(community => {
|
||||||
|
if (s.address.includes(community)) {
|
||||||
|
addressStats[community].count++
|
||||||
|
if (s.gender === '男') addressStats[community].male++
|
||||||
|
else if (s.gender === '女') addressStats[community].female++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredAddressStats = Object.entries(addressStats)
|
||||||
|
.filter(([_, v]) => v.count > 0)
|
||||||
|
.map(([name, v]) => ({ name, ...v }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
const targetCommunityTotal = filteredAddressStats.reduce((sum, s) => sum + s.count, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
genderStats,
|
||||||
|
classStats: Object.entries(classStats).map(([name, v]) => ({ name, ...v })),
|
||||||
|
ageGroups,
|
||||||
|
targetCommunities,
|
||||||
|
addressStats: filteredAddressStats,
|
||||||
|
targetCommunityTotal,
|
||||||
|
targetCommunityMale: filteredAddressStats.reduce((sum, s) => sum + s.male, 0),
|
||||||
|
targetCommunityFemale: filteredAddressStats.reduce((sum, s) => sum + s.female, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
11
server/api/students/[id].delete.ts
Normal file
11
server/api/students/[id].delete.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(event.context.params?.id)
|
||||||
|
|
||||||
|
await prisma.student.delete({
|
||||||
|
where: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
23
server/api/students/[id].put.ts
Normal file
23
server/api/students/[id].put.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = Number(event.context.params?.id)
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const student = await prisma.student.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
className: body.className,
|
||||||
|
name: body.name,
|
||||||
|
gender: body.gender,
|
||||||
|
birthday: body.birthday || null,
|
||||||
|
address: body.address || null,
|
||||||
|
fatherName: body.fatherName || null,
|
||||||
|
fatherPhone: body.fatherPhone || null,
|
||||||
|
motherName: body.motherName || null,
|
||||||
|
motherPhone: body.motherPhone || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return student
|
||||||
|
})
|
||||||
6
server/api/students/clear.post.ts
Normal file
6
server/api/students/clear.post.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await prisma.student.deleteMany()
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
28
server/api/students/import.post.ts
Normal file
28
server/api/students/import.post.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (!Array.isArray(body.students)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: 'Invalid data format'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.student.createMany({
|
||||||
|
data: body.students.map((s: any) => ({
|
||||||
|
className: s.className,
|
||||||
|
name: s.name,
|
||||||
|
gender: s.gender || '',
|
||||||
|
birthday: s.birthday || null,
|
||||||
|
address: s.address || null,
|
||||||
|
fatherName: s.fatherName || null,
|
||||||
|
fatherPhone: s.fatherPhone || null,
|
||||||
|
motherName: s.motherName || null,
|
||||||
|
motherPhone: s.motherPhone || null
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
return { count: result.count }
|
||||||
|
})
|
||||||
8
server/api/students/index.get.ts
Normal file
8
server/api/students/index.get.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const students = await prisma.student.findMany({
|
||||||
|
orderBy: { id: 'desc' }
|
||||||
|
})
|
||||||
|
return students
|
||||||
|
})
|
||||||
21
server/api/students/index.post.ts
Normal file
21
server/api/students/index.post.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from '~/server/utils/prisma'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
const student = await prisma.student.create({
|
||||||
|
data: {
|
||||||
|
className: body.className,
|
||||||
|
name: body.name,
|
||||||
|
gender: body.gender,
|
||||||
|
birthday: body.birthday || null,
|
||||||
|
address: body.address || null,
|
||||||
|
fatherName: body.fatherName || null,
|
||||||
|
fatherPhone: body.fatherPhone || null,
|
||||||
|
motherName: body.motherName || null,
|
||||||
|
motherPhone: body.motherPhone || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return student
|
||||||
|
})
|
||||||
16
server/utils/prisma.ts
Normal file
16
server/utils/prisma.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
|
|
||||||
|
// 默认目标小区配置
|
||||||
|
export const defaultCommunities = [
|
||||||
|
'恒大雅苑', '百悦尚城', '金域中央', '御泉山', '金域华府',
|
||||||
|
'金悦香树', '凯旋国际', '康华医院', '幸福公馆', '御花苑',
|
||||||
|
'景湖湾畔', '景湖时代城', '景湖花园', '香树丽舍', '恒大御景'
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user