feat: 初始化义工时长管理系统

- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG)
- 用户端:姓名查询个人义工时长与活动记录
- 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite)
- 支持 Docker 部署,附带 Gitea Actions 自动部署工作流
- 响应式界面,兼容移动端与 PC 端
- 默认管理员账号:admin / admin123
This commit is contained in:
Trae Assistant
2026-10-04 18:06:24 +08:00
commit 25b2b8f381
50 changed files with 7918 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
build
*.log
.DS_Store
.git
.gitignore
*.md
server/data
*.db
npm-debug.log*
README.md
.trae
+91
View File
@@ -0,0 +1,91 @@
name: Deploy to Server
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure npm mirror
run: |
echo "registry=https://registry.npmmirror.com" > ~/.npmrc
npm config set registry https://registry.npmmirror.com
- name: Build client (native)
working-directory: ./client
run: |
npm install --no-audit --no-fund
npm run build
- name: Build server (native)
working-directory: ./server
run: |
npm install --no-audit --no-fund
npm run build
- name: Install server production deps
working-directory: ./server
run: |
rm -rf node_modules
npm install --omit=dev --no-audit --no-fund
- name: Prepare deploy package
run: |
mkdir -p deploy-pkg/app
cp -r client/dist deploy-pkg/app/client/dist
cp -r server/dist deploy-pkg/app/server/dist
cp -r server/node_modules deploy-pkg/app/server/node_modules
cp -r server/src/db/schema.sql deploy-pkg/app/server/src/db/schema.sql 2>/dev/null || mkdir -p deploy-pkg/app/server/src/db && cp server/src/db/schema.sql deploy-pkg/app/server/src/db/
cp docker-compose.yml deploy-pkg/
cat > deploy-pkg/docker-compose.yml <<'EOF'
services:
volunteer:
image: node:20-alpine
container_name: volunteer-manage
restart: unless-stopped
working_dir: /app/server
command: sh -c "mkdir -p /app/client/dist /app/server/src/db && cp -r /tmp-src/* /app/ 2>/dev/null; [ -d /app/server/src/db ] || mkdir -p /app/server/src/db; node /app/server/dist/index.js"
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${{ secrets.JWT_SECRET || 'volunteer-manage-secret-key-2026' }}
volumes:
- /opt/volunteer/data:/app/server/data
- /opt/volunteer/app:/app:rw
EOF
- name: Install SSH key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.SSH_PRIVATE_KEY }}
known_hosts: unnecessary
if_key_exists: replace
- name: Deploy via SSH
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT || 22 }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
PORT="${SSH_PORT:-22}"
ssh -o StrictHostKeyChecking=no -p $PORT $SSH_USER@$SSH_HOST "mkdir -p /opt/volunteer/app /opt/volunteer/data"
scp -o StrictHostKeyChecking=no -P $PORT -r deploy-pkg/app/* $SSH_USER@$SSH_HOST:/opt/volunteer/app/
scp -o StrictHostKeyChecking=no -P $PORT deploy-pkg/docker-compose.yml $SSH_USER@$SSH_HOST:/opt/volunteer/docker-compose.yml
ssh -o StrictHostKeyChecking=no -p $PORT $SSH_USER@$SSH_HOST "cd /opt/volunteer && docker compose down || true && docker compose up -d --force-recreate && sleep 3 && docker compose ps"
- name: Health check
env:
SSH_HOST: ${{ secrets.SSH_HOST }}
SSH_PORT: ${{ secrets.SSH_PORT || 22 }}
SSH_USER: ${{ secrets.SSH_USER }}
run: |
PORT="${SSH_PORT:-22}"
ssh -o StrictHostKeyChecking=no -p $PORT $SSH_USER@$SSH_HOST "for i in 1 2 3 4 5; do if curl -fsS http://localhost:3000/u > /dev/null; then echo 'Service OK'; exit 0; fi; echo 'Wait...'; sleep 3; done; echo 'Service FAIL'; exit 1"
+18
View File
@@ -0,0 +1,18 @@
node_modules/
dist/
build/
*.log
.DS_Store
.env
.env.local
.env.*.local
server/data/*.db
server/data/*.db-journal
server/data/*.db-wal
server/data/*.db-shm
client/dist/
client/node_modules/
server/node_modules/
server/dist/
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmmirror.com
+202
View File
@@ -0,0 +1,202 @@
# 义工时长管理系统实施计划
## 仓库研究结论
- **Gitea 仓库**:`Henry_Du/VolunteerManage-for-byw`(空仓库,无任何提交)
- **本地工作目录**:`d:\义工时长系统`(空目录)
- **部署目标**:Docker 容器运行于服务器,域名 `volunteer.bzw-hheb.top`(反向代理)
- **CI/CD**:Gitea Actions 自动构建并部署
***
## 技术选型
| 层级 | 技术方案 | 选型理由 |
| ----- | ------------------------------------------ | ------------------------------ |
| 前端框架 | **React 18 + Vite 5 + TypeScript** | 开发效率高,响应式生态成熟 |
| UI 样式 | **TailwindCSS 3** | 快速构建硬边/极简响应式界面,契合用户 UI 偏好 |
| 组件库 | **shadcn/ui**(按需引入) | 轻量无样式组件,易定制硬边风格 |
| 后端框架 | **Node.js + Express + TypeScript** | 与前端同语言,部署简单 |
| 数据库 | **SQLite 3**(better-sqlite3) | 单文件数据库,无需额外容器;中小规模数据完全够用 |
| 认证 | **管理台:账号密码 + JWT**;**用户端:仅按姓名查询(无鉴权)** | 简单实用 |
| 导出 | **xlsx**(Excel 表格)、**html2canvas**(图片 PNG) | 表格+图片两种导出需求全覆盖 |
| 容器化 | **多阶段 Dockerfile**(构建前端 → 打包后端 + 静态资源) | 单容器部署,简单可靠 |
| CI/CD | **Gitea Actions** | 推送 main 分支自动构建镜像,通过 SSH 部署到服务器 |
***
## 文件与模块结构
```
VolunteerManage-for-byw/
├── client/ # 前端 React 项目
│ ├── src/
│ │ ├── components/ # 通用组件
│ │ ├── pages/
│ │ │ ├── admin/ # 管理台页面
│ │ │ │ ├── Login.tsx # 管理员登录
│ │ │ │ ├── Layout.tsx # 管理台布局 + 侧边栏
│ │ │ │ ├── Activities.tsx # ① 活动设置
│ │ │ │ ├── Hours.tsx # ② 义工时长登记 + 单次活动导出
│ │ │ │ ├── Users.tsx # ③ 用户管理(含个人详情)
│ │ │ │ └── Export.tsx # ④ 数据导出(多选导出项)
│ │ │ └── user/
│ │ │ ├── Index.tsx # 姓名输入页
│ │ │ └── Dashboard.tsx # 个人义工时长总览
│ │ ├── api/ # axios 封装与接口定义
│ │ ├── types/ # 共享类型
│ │ ├── App.tsx / main.tsx
│ │ └── index.css
│ ├── index.html
│ ├── vite.config.ts
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── package.json
├── server/ # 后端 Express 项目
│ ├── src/
│ │ ├── index.ts # 入口:挂载静态资源 + API 路由
│ │ ├── db/
│ │ │ ├── index.ts # SQLite 初始化 + 建表
│ │ │ └── schema.sql # 建表 SQL(users, groups, activities, hours)
│ │ ├── middleware/
│ │ │ └── auth.ts # JWT 鉴权中间件
│ │ ├── routes/
│ │ │ ├── auth.ts # POST /api/admin/login
│ │ │ ├── activities.ts # 活动 CRUD
│ │ │ ├── hours.ts # 时长登记 + 按活动查询
│ │ │ ├── users.ts # 部员 CRUD + 个人活动/累计时长
│ │ │ ├── groups.ts # 工作组 CRUD
│ │ │ └── export.ts # 各种导出接口(JSON 数据)
│ │ └── config.ts
│ ├── data/ # SQLite db 文件存放目录(Docker volume 挂载)
│ ├── tsconfig.json
│ └── package.json
├── .gitea/
│ └── workflows/
│ └── deploy.yml # Actions:构建镜像 → SSH 部署
├── Dockerfile # 多阶段构建
├── docker-compose.yml # 本地快速调试用
├── .dockerignore
├── .gitignore
├── .npmrc # 配置 npmmirror 镜像源
└── README.md
```
### 数据库表设计(SQLite)
| 表名 | 关键字段 | 说明 |
| ----------------- | --------------------------------------------------------------- | ------------------------------- |
| `work_groups` | id, name (唯一) | 工作组(如"策划部"、"宣传部") |
| `users` | id, class, name, volunteer\_no, group\_id (FK) | 部员信息;班级+姓名组合唯一 |
| `activities` | id, title, content, start\_time, end\_time, created\_at | 活动 |
| `activity_groups` | activity\_id, group\_id | 活动 ↔ 工作组 多对多关联 |
| `hours` | id, activity\_id, user\_id, hours(decimal), remark, created\_at | 义工时长登记(一人一次活动一条) |
| `admins` | id, username, password\_hash | 管理员账号(初始化一条默认:admin / admin123) |
***
## 实施步骤(按依赖顺序)
### 阶段 1:项目脚手架 & 基础配置
1. 创建根目录结构:`client/`、`server/`、`.gitea/workflows/`
2. 配置 `.npmrc` 使用 npmmirror 国内镜像
3. 初始化前端 Vite + React + TS + TailwindCSS + shadcn 基础依赖
4. 初始化后端 Express + TS + SQLite 基础依赖
5. 编写 `.gitignore`、`.dockerignore`
### 阶段 2:后端 API 实现
1. 编写 SQLite 初始化脚本(建表 + 插入默认管理员)
2. 实现认证路由:管理员登录(返回 JWT) + 鉴权中间件
3. 实现工作组 CRUD
4. 实现部员 CRUD(含所属工作组;查询个人参与活动及累计时长)
5. 实现活动 CRUD(含设置关联工作组)
6. 实现时长登记 CRUD(为某活动某部员设置时长+备注;按活动查询所有人的登记)
7. 实现导出路由:
- 所有成员 / 所有活动 / 所有时长明细(可多选)
- 按活动导出时长明细
- 所有部员累计总时长
- 所有部员参加的活动列表
### 阶段 3:前端页面实现
1. 前端基础:axios 封装、API 类型、路由(react-router-dom)
2. 用户端:
- 姓名输入首页(无鉴权,输入姓名查询匹配部员)
- 个人总览(总时长、参与活动列表)
3. 管理台:
- 登录页 + JWT 持久化(localStorage)+ 路由守卫
- 侧边栏布局(活动/时长/用户/导出四大模块)
- 活动设置页(列表、新建/编辑弹窗,设置工作组复选框)
- 时长登记页(选择活动 → 显示参与工作组所有成员 → 填写每人时长/备注 → 保存;提供导出为 Excel / 导出为图片按钮)
- 用户管理页(部员列表、新建/编辑弹窗、查看详情(累计时长+参与活动))
- 数据导出页(勾选要导出的内容:全部表格 / 总时长 / 活动记录 → 导出 Excel 或 图片)
### 阶段 4:容器化与部署
1. 编写 Dockerfile 多阶段构建:
- Stage 1:构建 client 生成静态资源
- Stage 2:构建 server TS → JS,复制 client/dist 作为静态资源
- Stage 3:node:20-alpine 运行,EXPOSE 3000,挂载 /app/server/data 卷
2. 编写 docker-compose.yml(本地调试)
3. 编写 Gitea Actions 工作流 `.gitea/workflows/deploy.yml`:
- 触发:push 到 main
- 步骤:checkout → 登录 Docker Registry(或直接本机构建后导出)→ 构建镜像 → 通过 SSH 连接服务器 → scp 复制 docker-compose(或 docker run)启动新容器 → 健康检查
### 阶段 5:本地验证 & 推送
1. 本地 `docker compose up` 验证系统各功能模块正常
2. 所有代码首次提交并推送到 Gitea main 分支,触发 Action 自动部署
3. 线上 `volunteer.bzw-hheb.top` 访问验证
***
## 依赖与注意事项
1. **npm 强制**:所有安装命令使用 `npm install`,不使用 pnpm。`.npmrc` 写入 `registry=https://registry.npmmirror.com`。
2. **管理员初始账号**:首次启动自动创建 `admin / admin123`,需在 README 提醒首次登录后修改(后端提供修改密码接口)。
3. **数据持久化**:SQLite 数据库文件位于容器内 `/app/server/data/volunteer.db`,部署时必须挂载为 Docker volume 或 bind mount,否则容器重建数据丢失。
4. **用户端查询**:按"姓名"匹配若有重名,展示列表让用户选择(班级+义工号辅助辨别)。
5. **图片导出**:前端将表格渲染成 HTML 后用 html2canvas 生成 PNG 下载;Excel 导出由前端拿到 JSON 数据后用 xlsx 生成。
6. **UI 风格**:TailwindCSS 默认禁用圆角(配置 `borderRadius: { DEFAULT: '0px' }` 或组件级手动零圆角),浅色/黑白专业风。
7. **响应式**:TailwindCSS 断点 `md:` 适配移动端(菜单变为汉堡按钮、表格水平滚动)。
8. **反向代理**:服务器上由 nginx/caddy 将 `volunteer.bzw-hheb.top` 反代到容器端口(Action 部署后由用户在服务器上已配置好反向代理)。
9. **Gitea Actions 机密(Secrets)**:用户需要在 Gitea 仓库设置中配置以下 Secrets:
- `SSH_HOST`:服务器 IP/域名
- `SSH_PORT`:SSH 端口(默认 22)
- `SSH_USER`:SSH 用户名
- `SSH_PRIVATE_KEY`:服务器 SSH 私钥
***
## 验证清单
- [ ] 管理台登录/退出
- [ ] 工作组增删改查
- [ ] 部员增删改查;查看个人累计时长与活动明细
- [ ] 活动发布(设置工作组);活动编辑/删除
- [ ] 活动时长登记(批量设置);修改/删除登记
- [ ] 单次活动导出:Excel 文件包含姓名、班级、义工号、工作组、时长、备注
- [ ] 单次活动导出:图片样式美观、中文不乱码
- [ ] 总数据导出:支持多选导出项;Excel 多 Sheet 或分文件
- [ ] 所有部员总时长导出(按时长降序)
- [ ] 所有部员参加的活动列表导出
- [ ] 用户端:输入姓名→选择部员→展示总时长与活动列表
- [ ] 移动端:关键页面无横滚(除表格内滚动),点击区域适合手指
- [ ] Docker 构建成功并运行;`/app/server/data` 持久化正常
- [ ] Gitea Actions push main 后自动部署成功
- [ ] <https://volunteer.bzw-hheb.top> 可访问各页面
***
## 风险与处理
| 风险 | 处理方案 |
| ------------------------------ | ----------------------------------------------------- |
| html2canvas 在中文/表格复杂时截图截断或样式偏差 | 对导出区域设置固定宽高、关闭动画、使用 `useCORS:true`;提供 Excel 导出作为可靠主力 |
| 重名部员导致用户端查询混乱 | 列出所有匹配项,展示班级+义工号让用户二次确认 |
| SQLite 并发写入性能 | 义工系统写入量低(仅管理员操作),无并发瓶颈;使用 WAL 模式(better-sqlite3 默认支持) |
| Action SSH 部署失败 | 先本地手动部署验证一次后再启用 Action;提供备用手动部署步骤(README) |
| 服务器反向代理未配置 WebSocket(如有需要) | 本系统无 WebSocket 实时推送,纯 HTTP 即可 |
+32
View File
@@ -0,0 +1,32 @@
FROM node:20-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY client/ ./
RUN npm run build
FROM node:20-alpine AS server-build
WORKDIR /app/server
COPY server/package.json server/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY server/ ./
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=client-build /app/client/dist ./client/dist
COPY --from=server-build /app/server/dist ./server/dist
COPY server/src/db/schema.sql ./server/src/db/schema.sql
RUN mkdir -p ./server/data
VOLUME ["/app/server/data"]
EXPOSE 3000
WORKDIR /app/server
CMD ["node", "dist/index.js"]
+61
View File
@@ -0,0 +1,61 @@
# 义工时长管理系统
## 功能概述
- **管理台**:活动设置、义工时长登记、用户管理、数据导出(表格/图片)
- **用户端**:输入姓名查询个人参与活动及累计义工时长
- **响应式**:同时适配移动设备与 PC 端
## 技术栈
- 前端:React 18 + Vite 5 + TypeScript + TailwindCSS 3
- 后端:Node.js + Express + TypeScript
- 数据库:SQLite 3 (better-sqlite3)
- 部署:Docker + Gitea Actions 自动部署
- 域名:https://volunteer.bzw-hheb.top
## 初始管理员账号
- 用户名:`admin`
- 密码:`admin123`
首次登录后请立即修改密码。
## 本地开发
```bash
# 安装后端依赖
cd server && npm install
# 安装前端依赖
cd ../client && npm install
# 启动后端(端口 3001)
cd server && npm run dev
# 启动前端(端口 5173,代理 /api 到 3001)
cd client && npm run dev
```
## Docker 本地运行
```bash
docker compose up -d --build
```
访问 http://localhost:3000
## 部署配置(Gitea Secrets)
在仓库 Settings -> Secrets 中配置:
| 名称 | 说明 |
|------|------|
| `SSH_HOST` | 服务器 IP/域名 |
| `SSH_PORT` | SSH 端口(默认 22) |
| `SSH_USER` | SSH 用户名 |
| `SSH_PRIVATE_KEY` | SSH 私钥(对应服务器上的公钥需加入 authorized_keys) |
## 数据持久化
SQLite 数据库文件位于容器 `/app/server/data/volunteer.db`,docker-compose.yml 中已配置为 bind mount 到 `./server/data`。
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕒</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>义工时长管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3171
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "volunteer-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"dayjs": "^1.11.13",
"html2canvas": "^1.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+28
View File
@@ -0,0 +1,28 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import UserIndex from './pages/user/Index';
import UserDashboard from './pages/user/Dashboard';
import AdminLogin from './pages/admin/Login';
import AdminLayout from './pages/admin/Layout';
import Activities from './pages/admin/Activities';
import Hours from './pages/admin/Hours';
import Users from './pages/admin/Users';
import Export from './pages/admin/Export';
export default function App() {
return (
<Routes>
<Route path="/" element={<Navigate to="/u" replace />} />
<Route path="/u" element={<UserIndex />} />
<Route path="/u/:id" element={<UserDashboard />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="activities" replace />} />
<Route path="activities" element={<Activities />} />
<Route path="hours" element={<Hours />} />
<Route path="users" element={<Users />} />
<Route path="export" element={<Export />} />
</Route>
<Route path="*" element={<Navigate to="/u" replace />} />
</Routes>
);
}
+86
View File
@@ -0,0 +1,86 @@
import axios from 'axios';
import type {
WorkGroup, UserItem, UserDetail, ActivityItem, Participant,
HoursActivityResult, ExportResult,
} from '../types';
const api = axios.create({
baseURL: '/api',
timeout: 15000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('volunteer_admin_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('volunteer_admin_token');
localStorage.removeItem('volunteer_admin_username');
if (!window.location.pathname.startsWith('/admin/login')) {
window.location.href = '/admin/login';
}
}
return Promise.reject(err);
}
);
export const errMsg = (e: any, def = '操作失败'): string => {
return e?.response?.data?.error || e?.message || def;
};
export default api;
export const authApi = {
login: (username: string, password: string) =>
api.post<{ token: string; username: string }>('/auth/login', { username, password }).then(r => r.data),
changePassword: (oldPassword: string, newPassword: string) =>
api.post('/auth/change-password', { oldPassword, newPassword }).then(r => r.data),
};
export const groupsApi = {
list: () => api.get<WorkGroup[]>('/groups').then(r => r.data),
create: (name: string) => api.post<WorkGroup>('/groups', { name }).then(r => r.data),
update: (id: number, name: string) => api.put(`/groups/${id}`, { name }).then(r => r.data),
remove: (id: number) => api.delete(`/groups/${id}`).then(r => r.data),
};
export const usersApi = {
list: () => api.get<UserItem[]>('/users').then(r => r.data),
search: (name: string) => api.get<UserItem[]>('/users/search', { params: { name } }).then(r => r.data),
detail: (id: number) => api.get<UserDetail>(`/users/${id}`).then(r => r.data),
create: (data: Partial<UserItem>) => api.post('/users', data).then(r => r.data),
update: (id: number, data: Partial<UserItem>) => api.put(`/users/${id}`, data).then(r => r.data),
remove: (id: number) => api.delete(`/users/${id}`).then(r => r.data),
};
export const activitiesApi = {
list: () => api.get<ActivityItem[]>('/activities').then(r => r.data),
detail: (id: number) => api.get<ActivityItem>(`/activities/${id}`).then(r => r.data),
create: (data: any) => api.post('/activities', data).then(r => r.data),
update: (id: number, data: any) => api.put(`/activities/${id}`, data).then(r => r.data),
remove: (id: number) => api.delete(`/activities/${id}`).then(r => r.data),
participants: (id: number) => api.get<{ activity: ActivityItem; list: Participant[] }>(`/activities/${id}/participants`).then(r => r.data),
};
export const hoursApi = {
batch: (activity_id: number, items: Array<{ user_id: number; hours: number; remark: string }>) =>
api.post('/hours/batch', { activity_id, items }).then(r => r.data),
byActivity: (id: number) => api.get<HoursActivityResult>(`/hours/activity/${id}`).then(r => r.data),
};
export const exportApi = {
data: (opts: {
include_users?: boolean;
include_activities?: boolean;
include_hours?: boolean;
include_user_totals?: boolean;
include_user_activities?: boolean;
}) => api.post<ExportResult>('/export', opts).then(r => r.data),
};
+23
View File
@@ -0,0 +1,23 @@
import { ButtonHTMLAttributes, ReactNode } from 'react';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children?: ReactNode;
}
export default function Button({ variant = 'primary', size = 'md', className = '', children, ...rest }: Props) {
const base = 'inline-flex items-center justify-center font-medium border-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
const sizeCls = size === 'sm' ? 'px-3 py-1.5 text-sm' : size === 'lg' ? 'px-6 py-3 text-lg' : 'px-4 py-2 text-base';
const variantCls = {
primary: 'bg-black text-white border-black hover:bg-gray-800 hover:border-gray-800',
secondary: 'bg-white text-black border-gray-300 hover:bg-gray-100 hover:border-gray-400',
danger: 'bg-red-500 text-white border-red-500 hover:bg-red-600 hover:border-red-600',
ghost: 'bg-transparent text-black border-transparent hover:bg-gray-100',
}[variant];
return (
<button {...rest} className={`${base} ${sizeCls} ${variantCls} ${className}`}>
{children}
</button>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
interface Column<T> {
key: string;
title: string;
width?: string;
render?: (row: T, index: number) => ReactNode;
}
interface Props<T> {
columns: Column<T>[];
data: T[];
emptyText?: string;
rowKey?: (row: T, index: number) => string | number;
className?: string;
id?: string;
}
export default function DataTable<T>({ columns, data, emptyText = '暂无数据', rowKey, className = '', id }: Props<T>) {
return (
<div className={`w-full overflow-x-auto border-2 border-gray-200 bg-white ${className}`}>
<table id={id} className="w-full text-sm md:text-base">
<thead>
<tr className="bg-gray-100 border-b-2 border-black">
{columns.map((c) => (
<th key={c.key} style={c.width ? { width: c.width } : undefined} className="px-3 py-2.5 text-left font-bold text-black whitespace-nowrap">
{c.title}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-3 py-10 text-center text-gray-500">{emptyText}</td>
</tr>
) : (
data.map((row, idx) => (
<tr key={rowKey ? rowKey(row, idx) : idx} className="border-b border-gray-200 hover:bg-gray-50">
{columns.map((c) => (
<td key={c.key} className="px-3 py-2.5 align-top">
{c.render ? c.render(row, idx) : (row as any)[c.key]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { InputHTMLAttributes } from 'react';
export default function Input(props: InputHTMLAttributes<HTMLInputElement>) {
const { className = '', ...rest } = props;
return (
<input
{...rest}
className={`w-full px-3 py-2 border-2 border-gray-300 bg-white text-black text-sm md:text-base transition-colors ${className}`}
/>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { ReactNode, useEffect } from 'react';
import Button from './Button';
interface Props {
open: boolean;
title: string;
children?: ReactNode;
onClose: () => void;
footer?: ReactNode;
width?: string;
}
export default function Modal({ open, title, children, onClose, footer, width = 'max-w-2xl' }: Props) {
useEffect(() => {
if (open) document.body.style.overflow = 'hidden';
else document.body.style.overflow = '';
return () => { document.body.style.overflow = ''; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
<div className={`bg-white w-full ${width} max-h-[90vh] flex flex-col border-2 border-black`} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b-2 border-black bg-gray-50">
<h3 className="font-bold text-lg">{title}</h3>
<Button variant="ghost" size="sm" onClick={onClose}>✕</Button>
</div>
<div className="flex-1 p-4 overflow-auto">{children}</div>
{footer && <div className="px-4 py-3 border-t-2 border-gray-200 bg-gray-50 flex justify-end gap-2 flex-wrap">{footer}</div>}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { SelectHTMLAttributes, ReactNode } from 'react';
interface Props extends SelectHTMLAttributes<HTMLSelectElement> {
children?: ReactNode;
}
export default function Select({ className = '', children, ...rest }: Props) {
return (
<select {...rest} className={`w-full px-3 py-2 border-2 border-gray-300 bg-white text-sm md:text-base transition-colors ${className}`}>
{children}
</select>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { TextareaHTMLAttributes } from 'react';
export default function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
const { className = '', ...rest } = props;
return (
<textarea {...rest} className={`w-full px-3 py-2 border-2 border-gray-300 bg-white text-sm md:text-base transition-colors ${className}`} />
);
}
+33
View File
@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
background: #FAFAFA;
color: #171717;
}
table {
border-collapse: collapse;
}
button {
cursor: pointer;
}
input, select, textarea {
outline: none;
}
input:focus, select:focus, textarea:focus {
border-color: #2563EB !important;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import Button from '../../components/Button';
import DataTable from '../../components/DataTable';
import Modal from '../../components/Modal';
import Input from '../../components/Input';
import Select from '../../components/Select';
import Textarea from '../../components/Textarea';
import { activitiesApi, groupsApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export';
import type { ActivityItem, WorkGroup } from '../../types';
type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] };
const emptyForm: Form = { title: '', content: '', start_time: '', end_time: '', groups: [] };
function toLocalInput(d: Date) {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function Activities() {
const [list, setList] = useState<ActivityItem[]>([]);
const [groups, setGroups] = useState<WorkGroup[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<Form>(emptyForm);
const [editId, setEditId] = useState<number | null>(null);
const [error, setError] = useState('');
const [groupsOpen, setGroupsOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [groupsError, setGroupsError] = useState('');
const load = async () => {
setLoading(true);
try {
const [acts, gs] = await Promise.all([activitiesApi.list(), groupsApi.list()]);
setList(acts);
setGroups(gs);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const openNew = () => {
const now = new Date();
const end = new Date(now.getTime() + 2 * 3600 * 1000);
setForm({ ...emptyForm, start_time: toLocalInput(now), end_time: toLocalInput(end) });
setEditId(null);
setError('');
setModalOpen(true);
};
const openEdit = async (a: ActivityItem) => {
try {
const d = await activitiesApi.detail(a.id);
setForm({ title: d.title, content: d.content || '', start_time: d.start_time.replace(' ', 'T').slice(0, 16), end_time: d.end_time.replace(' ', 'T').slice(0, 16), groups: d.groups || [] });
setEditId(a.id);
setError('');
setModalOpen(true);
} catch (e) { alert(errMsg(e)); }
};
const onDelete = async (a: ActivityItem) => {
if (!confirm(`确认删除活动「${a.title}」?该活动的时长登记也会删除。`)) return;
try { await activitiesApi.remove(a.id); load(); } catch (e) { alert(errMsg(e)); }
};
const toggleGroup = (id: number) => {
setForm(f => f.groups.includes(id) ? { ...f, groups: f.groups.filter(g => g !== id) } : { ...f, groups: [...f.groups, id] });
};
const submit = async () => {
if (!form.title.trim()) { setError('活动标题不能为空'); return; }
if (!form.start_time || !form.end_time) { setError('活动时间不能为空'); return; }
setError('');
try {
const payload = { ...form, start_time: form.start_time.replace('T', ' '), end_time: form.end_time.replace('T', ' ') };
if (editId) await activitiesApi.update(editId, payload);
else await activitiesApi.create(payload);
setModalOpen(false);
load();
} catch (e) { setError(errMsg(e)); }
};
const addGroup = async () => {
const n = newGroupName.trim();
if (!n) { setGroupsError('请输入名称'); return; }
try {
await groupsApi.create(n);
setNewGroupName('');
setGroupsError('');
const gs = await groupsApi.list();
setGroups(gs);
} catch (e) { setGroupsError(errMsg(e)); }
};
const removeGroup = async (g: WorkGroup) => {
if (!confirm(`删除工作组「${g.name}」?已关联的部员会解除关联。`)) return;
try { await groupsApi.remove(g.id); const gs = await groupsApi.list(); setGroups(gs); } catch (e) { alert(errMsg(e)); }
};
return (
<div>
<div className="flex justify-between items-start flex-wrap gap-3 mb-5">
<div>
<h1 className="text-xl md:text-2xl font-black mb-1">📅 活动设置</h1>
<p className="text-sm text-gray-500">发布、管理活动,并设置参与的工作组</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="secondary" onClick={() => setGroupsOpen(true)}>管理工作组</Button>
<Button onClick={openNew}>+ 新建活动</Button>
</div>
</div>
<DataTable<ActivityItem>
columns={[
{ key: 'title', title: '活动标题', width: '26%', render: (r) => <span className="font-bold">{r.title}</span> },
{ key: 'time', title: '活动时间', render: (r) => formatDateRange(r.start_time, r.end_time) },
{ key: 'groups', title: '参与工作组', render: (r) => <span className="text-sm">{(r.groups_name_list || []).join('、') || '全部'}</span> },
{ key: 'created_at', title: '创建时间', width: '18%', render: (r) => String(r.created_at || '').slice(0, 16) },
{
key: 'op', title: '操作', width: '180px', render: (r) => (
<div className="flex gap-1.5 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}>编辑</Button>
<Button size="sm" variant="danger" onClick={() => onDelete(r)}>删除</Button>
</div>
)
},
]}
data={list}
rowKey={(r) => r.id}
/>
<Modal open={modalOpen} title={editId ? '编辑活动' : '新建活动'} onClose={() => setModalOpen(false)}
footer={<><Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button><Button onClick={submit}>{editId ? '保存修改' : '发布活动'}</Button></>}>
<div className="space-y-4">
<div><label className="block text-sm font-bold mb-1.5">活动标题 *</label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:2026 元旦联欢志愿服务" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label className="block text-sm font-bold mb-1.5">开始时间 *</label>
<Input type="datetime-local" value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} />
</div>
<div><label className="block text-sm font-bold mb-1.5">结束时间 *</label>
<Input type="datetime-local" value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} />
</div>
</div>
<div><label className="block text-sm font-bold mb-1.5">活动内容</label>
<Textarea rows={3} value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} />
</div>
<div>
<label className="block text-sm font-bold mb-1.5">参与的工作组(不选则默认全部部员可选)</label>
{groups.length === 0 ? (
<div className="text-sm text-gray-500 p-3 border-2 border-dashed border-gray-300">暂无工作组,可先去「管理工作组」添加</div>
) : (
<div className="flex gap-2 flex-wrap">
{groups.map((g) => (
<label key={g.id} className={`flex items-center gap-2 px-3 py-2 border-2 cursor-pointer ${form.groups.includes(g.id) ? 'border-black bg-black text-white' : 'border-gray-300 bg-white text-black'}`}>
<input type="checkbox" className="hidden" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
<span className="text-sm font-medium">{g.name}</span>
</label>
))}
</div>
)}
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
</div>
</Modal>
<Modal open={groupsOpen} title="工作组管理" onClose={() => setGroupsOpen(false)}
footer={<Button variant="secondary" onClick={() => setGroupsOpen(false)}>关闭</Button>}>
<div className="space-y-4">
<div className="flex gap-2">
<Input placeholder="输入新工作组名称,如:策划部" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
<Button onClick={addGroup}>添加</Button>
</div>
{groupsError && <div className="text-sm text-red-600">{groupsError}</div>}
<DataTable<WorkGroup>
columns={[
{ key: 'name', title: '工作组名称', render: (r) => <span className="font-medium">{r.name}</span> },
{ key: 'op', title: '操作', width: '100px', render: (r) => <Button size="sm" variant="danger" onClick={() => removeGroup(r)}>删除</Button> }
]}
data={groups}
rowKey={(r) => r.id}
/>
</div>
</Modal>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useRef, useState } from 'react';
import Button from '../../components/Button';
import { exportApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export';
import type { ExportResult } from '../../types';
type Opts = {
include_users: boolean;
include_activities: boolean;
include_hours: boolean;
include_user_totals: boolean;
include_user_activities: boolean;
};
const defaultOpts: Opts = {
include_users: true,
include_activities: true,
include_hours: true,
include_user_totals: true,
include_user_activities: false,
};
export default function Export() {
const [opts, setOpts] = useState<Opts>(defaultOpts);
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<ExportResult | null>(null);
const previewRef = useRef<HTMLDivElement>(null);
const toggle = (k: keyof Opts) => setOpts(o => ({ ...o, [k]: !o[k] }));
const anySelected = Object.values(opts).some(Boolean);
const load = async () => {
if (!anySelected) { alert('请至少选择一项导出内容'); return; }
setLoading(true); setPreview(null);
try {
const d = await exportApi.data(opts);
setPreview(d);
} catch (e) {
alert(errMsg(e));
} finally { setLoading(false); }
};
const doExcel = () => {
if (!preview) return alert('请先生成预览');
const sheets: Array<{ name: string; data: any[] }> = [];
if (preview.users?.length) sheets.push({ name: '部员名册', data: preview.users.map((u, i) => ({ '序号': i + 1, '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '所属工作组': u.group_name || '—' })) });
if (preview.activities?.length) sheets.push({ name: '活动列表', data: preview.activities.map((a, i) => ({ '序号': i + 1, '活动标题': a.title, '内容': a.content || '', '开始时间': a.start_time, '结束时间': a.end_time, '参与工作组': a.groups_name || '', '创建时间': (a.created_at || '').slice(0, 16) })) });
if (preview.hours?.length) sheets.push({ name: '全部时长明细', data: preview.hours.map((h: any, i: number) => ({ '序号': i + 1, '班级': h.class, '姓名': h.name, '义工号': h.volunteer_no || '—', '工作组': h.group_name || '—', '活动名称': h.activity_title, '活动时间': formatDateRange(h.start_time, h.end_time), '时长(小时)': Number(h.hours || 0).toFixed(2), '备注': h.remark || '—', '登记时间': (h.created_at || '').slice(0, 16) })) });
if (preview.user_totals?.length) sheets.push({ name: '部员总时长', data: preview.user_totals.map((u, i) => ({ '排名': i + 1, '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '参与活动数': u.activity_count, '累计时长(小时)': Number(u.total_hours || 0).toFixed(2) })) });
if (preview.user_activities?.length) {
const flat: any[] = [];
preview.user_activities.forEach(u => {
if (u.activities.length === 0) flat.push({ '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '活动名称': '(未参与任何活动)', '时间': '', '时长(小时)': '', '备注': '' });
u.activities.forEach(a => flat.push({ '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '活动名称': a.title, '时间': formatDateRange(a.start_time, a.end_time), '时长(小时)': Number(a.hours || 0).toFixed(2), '备注': a.remark || '—' }));
});
sheets.push({ name: '部员活动明细', data: flat });
}
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0,10)}.xlsx`);
};
const doImage = async () => {
if (!previewRef.current) return;
await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0,10)}.png`);
};
const Row = ({ title, desc, value, onChange }: { title: string; desc: string; value: boolean; onChange: () => void }) => (
<label className={`block p-4 border-2 cursor-pointer transition ${value ? 'border-black bg-gray-50' : 'border-gray-200 bg-white hover:border-gray-400'}`}>
<div className="flex items-start gap-3">
<input type="checkbox" className="mt-1 w-4 h-4" checked={value} onChange={onChange} />
<div className="flex-1">
<div className="font-bold text-black">{title}</div>
<div className="text-sm text-gray-600 mt-0.5">{desc}</div>
</div>
</div>
</label>
);
return (
<div>
<div className="mb-5">
<h1 className="text-xl md:text-2xl font-black mb-1">📤 数据导出</h1>
<p className="text-sm text-gray-500">勾选需要导出的内容,预览后一键导出为 Excel 表格或 PNG 图片</p>
</div>
<div className="bg-white border-2 border-black p-4 md:p-5 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<Row title="① 部员名册" desc="导出所有部员的班级、姓名、义工号与所属工作组" value={opts.include_users} onChange={() => toggle('include_users')} />
<Row title="② 活动列表" desc="导出所有活动的标题、时间、参与工作组" value={opts.include_activities} onChange={() => toggle('include_activities')} />
<Row title="③ 全部时长明细" desc="所有部员在所有活动中的时长登记记录(含备注)" value={opts.include_hours} onChange={() => toggle('include_hours')} />
<Row title="④ 部员总义工时长" desc="所有部员各自的累计总时长与活动数(按时长降序排名)" value={opts.include_user_totals} onChange={() => toggle('include_user_totals')} />
<Row title="⑤ 部员参加的活动" desc="每位部员参与过的所有活动明细(活动较多时文件较大)" value={opts.include_user_activities} onChange={() => toggle('include_user_activities')} />
</div>
<div className="flex gap-2 flex-wrap justify-end">
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>{loading ? '生成中...' : '生成预览'}</Button>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>📊 导出 Excel</Button>
<Button onClick={doImage} disabled={!preview}>🖼 导出为图片</Button>
</div>
</div>
{preview && (
<div ref={previewRef} className="bg-white border-2 border-gray-200 p-3 md:p-5 space-y-6">
<div className="border-b-2 border-black pb-3 text-center hidden md:block">
<div className="text-xl font-black">义工数据汇总导出</div>
<div className="text-sm text-gray-600 mt-1">生成时间:{new Date().toLocaleString('zh-CN')}</div>
</div>
{preview.users?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">① 部员名册({preview.users.length} 人)</h3>
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '—', u.group_name || '—'])} />
</section>
)}
{preview.activities?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">② 活动列表({preview.activities.length} 个)</h3>
<MiniTable heads={['序号', '活动标题', '时间', '参与工作组']} rows={preview.activities.map((a, i) => [i + 1, a.title, formatDateRange(a.start_time, a.end_time), a.groups_name || '全部'])} />
</section>
)}
{preview.hours?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">③ 全部时长明细({preview.hours.length} 条)</h3>
<MiniTable heads={['班级', '姓名', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any) => [h.class, h.name, h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '—'])} />
</section>
)}
{preview.user_totals?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">④ 部员总时长排行</h3>
<MiniTable heads={['排名', '班级', '姓名', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.group_name || '—', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} />
</section>
)}
{preview.user_activities?.length && (
<section>
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">⑤ 部员参加的活动</h3>
<div className="space-y-4">
{preview.user_activities.map(u => (
<div key={u.id} className="border-2 border-gray-200">
<div className="bg-gray-50 px-3 py-2 border-b-2 border-gray-200 font-bold">
{u.class} {u.name} {u.group_name ? `(${u.group_name})` : ''}
<span className="text-gray-500 font-normal text-sm ml-3">共 {u.activities.length} 次活动</span>
</div>
{u.activities.length === 0
? <div className="p-3 text-sm text-gray-500">未参与任何活动</div>
: <MiniTable heads={['活动名称', '时间', '时长(h)', '备注']} rows={u.activities.map(a => [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(2), a.remark || '—'])} />}
</div>
))}
</div>
</section>
)}
</div>
)}
</div>
);
}
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
return (
<div className="w-full overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 border-b-2 border-black">
{heads.map((h, i) => <th key={i} className="px-3 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">{h}</th>)}
</tr>
</thead>
<tbody>
{rows.map((r, ri) => (
<tr key={ri} className="border-b border-gray-200">
{r.map((c, ci) => <td key={ci} className="px-3 py-1.5 border border-gray-200 whitespace-nowrap">{c}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Button from '../../components/Button';
import Select from '../../components/Select';
import Input from '../../components/Input';
import { activitiesApi, hoursApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export';
import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types';
export default function Hours() {
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [selectedId, setSelectedId] = useState<number | ''>('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<HoursActivityResult | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [activityInfo, setActivityInfo] = useState<ActivityItem | null>(null);
const [msg, setMsg] = useState('');
const tableRef = useRef<HTMLDivElement>(null);
useEffect(() => {
activitiesApi.list().then(setActivities).catch(e => alert(errMsg(e)));
}, []);
const loadParticipants = async (id: number) => {
setLoading(true);
setMsg('');
setParticipants([]);
setResult(null);
try {
const [p, r] = await Promise.all([activitiesApi.participants(id), hoursApi.byActivity(id)]);
setActivityInfo(p.activity);
const existingMap: Record<number, HoursRow> = {};
for (const row of r.list) existingMap[row.user_id] = row;
const merged: Participant[] = p.list.map(u => existingMap[u.id]
? { ...u, hours: existingMap[u.id].hours, remark: existingMap[u.id].remark || '', hours_id: existingMap[u.id].id }
: u);
setParticipants(merged);
setResult(r);
} catch (e) {
alert(errMsg(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (selectedId) loadParticipants(Number(selectedId));
else { setParticipants([]); setActivityInfo(null); setResult(null); }
}, [selectedId]);
const updateRow = (idx: number, field: 'hours' | 'remark', value: string) => {
setParticipants(list => list.map((p, i) => {
if (i !== idx) return p;
return { ...p, [field]: field === 'hours' ? Number(value) || 0 : value };
}));
};
const fillAll = (val: string) => {
const n = Number(val) || 0;
setParticipants(list => list.map(p => ({ ...p, hours: n })));
};
const save = async () => {
if (!selectedId) return;
setSaving(true);
setMsg('');
try {
const items = participants.map(p => ({ user_id: p.id, hours: Number(p.hours) || 0, remark: String(p.remark || '') }));
await hoursApi.batch(Number(selectedId), items);
setMsg('✓ 已保存');
loadParticipants(Number(selectedId));
} catch (e) {
setMsg(errMsg(e, '保存失败'));
} finally {
setSaving(false);
}
};
const summary = useMemo(() => {
const n = participants.filter(p => (p.hours || 0) > 0).length;
const h = participants.reduce((s, p) => s + (Number(p.hours) || 0), 0);
return { people: n, hours: h };
}, [participants]);
const doExcelExport = () => {
if (!result && !activityInfo) { alert('请先选择活动'); return; }
const title = activityInfo?.title || '活动';
const data = participants.filter(p => (p.hours || 0) > 0 || p.remark).map((p, i) => ({
'序号': i + 1,
'班级': p.class,
'姓名': p.name,
'义工号': p.volunteer_no || '—',
'工作组': p.group_name || '—',
'义工时长(小时)': Number(p.hours || 0).toFixed(2),
'备注': p.remark || '—',
}));
const meta = [{ '活动名称': title, '活动时间': formatDateRange(activityInfo?.start_time || '', activityInfo?.end_time || ''), '登记人数': summary.people, '总时长(小时)': summary.hours.toFixed(2) }];
exportToExcel([{ name: '汇总', data: meta }, { name: '时长明细', data }], `义工时长-${title}-${new Date().toISOString().slice(0,10)}.xlsx`);
};
const doImageExport = async () => {
if (!tableRef.current) return;
const title = activityInfo?.title || '活动';
await exportTableToImage(tableRef.current, `义工时长-${title}-${new Date().toISOString().slice(0,10)}.png`);
};
return (
<div>
<div className="mb-5">
<h1 className="text-xl md:text-2xl font-black mb-1">⏱️ 义工时长登记</h1>
<p className="text-sm text-gray-500">选择活动 → 填写每位部员的时长与备注 → 保存并导出</p>
</div>
<div className="bg-white border-2 border-black p-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
<div>
<label className="block text-sm font-bold mb-1.5">选择活动</label>
<Select value={String(selectedId)} onChange={(e) => setSelectedId(e.target.value === '' ? '' : Number(e.target.value))}>
<option value="">-- 请选择活动 --</option>
{activities.map(a => <option key={a.id} value={a.id}>{a.title} ({formatDateRange(a.start_time, a.end_time)})</option>)}
</Select>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="secondary" onClick={() => { const v = prompt('统一设置时长(小时)', '2'); if (v !== null) fillAll(v); }} disabled={!selectedId}>批量填时长</Button>
<Button variant="secondary" onClick={doExcelExport} disabled={!selectedId}>📊 导出 Excel</Button>
<Button variant="secondary" onClick={doImageExport} disabled={!selectedId}>🖼 导出图片</Button>
<Button onClick={save} disabled={!selectedId || saving}>{saving ? '保存中...' : '💾 保存登记'}</Button>
</div>
</div>
{activityInfo && (
<div className="mt-4 pt-4 border-t-2 border-gray-200 grid grid-cols-2 md:grid-cols-4 gap-3">
<div><div className="text-xs text-gray-500">活动名称</div><div className="font-bold truncate">{activityInfo.title}</div></div>
<div><div className="text-xs text-gray-500">活动时间</div><div className="font-medium text-sm">{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div></div>
<div><div className="text-xs text-gray-500">已登记/总人数</div><div className="font-bold">{summary.people} / {participants.length}</div></div>
<div><div className="text-xs text-gray-500">总时长</div><div className="font-bold">{summary.hours.toFixed(2)} 小时</div></div>
</div>
)}
{msg && <div className="mt-3 text-sm">{msg.includes('✓') ? <span className="text-green-600 font-medium">{msg}</span> : <span className="text-red-600">{msg}</span>}</div>}
</div>
{loading && <div className="p-10 text-center text-gray-500">加载中...</div>}
{!loading && selectedId && (
<div ref={tableRef} className="bg-white p-2 md:p-4 border-2 border-gray-200">
{activityInfo && (
<div className="mb-3 pb-3 border-b-2 border-black hidden md:block">
<div className="text-center font-black text-xl mb-1">义工时长登记表</div>
<div className="text-center text-sm text-gray-700">活动:{activityInfo.title} | 时间:{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div>
</div>
)}
<div className="w-full overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-100 border-b-2 border-black">
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">序号</th>
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">班级</th>
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">姓名</th>
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">义工号</th>
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">工作组</th>
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap" style={{ minWidth: '110px' }}>时长(小时)</th>
<th className="px-2 py-2 text-left font-bold border border-gray-200" style={{ minWidth: '200px' }}>备注</th>
</tr>
</thead>
<tbody>
{participants.length === 0 ? (
<tr><td colSpan={7} className="px-3 py-10 text-center text-gray-500 border border-gray-200">
该活动暂无部员可登记(若选了工作组,请确认该工作组下有部员)
</td></tr>
) : participants.map((p, idx) => (
<tr key={p.id} className="border-b border-gray-200">
<td className="px-2 py-2 border border-gray-200">{idx + 1}</td>
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.class}</td>
<td className="px-2 py-2 border border-gray-200 font-bold whitespace-nowrap">{p.name}</td>
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.volunteer_no || '—'}</td>
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.group_name || '—'}</td>
<td className="px-2 py-1 border border-gray-200">
<Input type="number" step="0.1" min="0" value={String(p.hours)} onChange={(e) => updateRow(idx, 'hours', e.target.value)} className="py-1" />
</td>
<td className="px-2 py-1 border border-gray-200">
<Input value={p.remark} onChange={(e) => updateRow(idx, 'remark', e.target.value)} placeholder="工作内容/备注" className="py-1" />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!selectedId && !loading && (
<div className="bg-white border-2 border-dashed border-gray-300 p-10 text-center text-gray-500">
请先在上方选择一个活动开始登记
</div>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { NavLink, Outlet, useNavigate, Navigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import Button from '../../components/Button';
import Modal from '../../components/Modal';
import Input from '../../components/Input';
import { authApi, errMsg } from '../../api';
const menu = [
{ path: '/admin/activities', label: '活动设置', icon: '📅' },
{ path: '/admin/hours', label: '时长登记', icon: '⏱️' },
{ path: '/admin/users', label: '用户管理', icon: '👥' },
{ path: '/admin/export', label: '数据导出', icon: '📤' },
];
export default function Layout() {
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const [pwdOpen, setPwdOpen] = useState(false);
const [oldPwd, setOldPwd] = useState('');
const [newPwd, setNewPwd] = useState('');
const [pwdErr, setPwdErr] = useState('');
const username = localStorage.getItem('volunteer_admin_username') || '';
const token = localStorage.getItem('volunteer_admin_token');
useEffect(() => {
const handler = () => setMenuOpen(false);
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
if (!token) return <Navigate to="/admin/login" replace />;
const logout = () => {
localStorage.removeItem('volunteer_admin_token');
localStorage.removeItem('volunteer_admin_username');
navigate('/admin/login');
};
const doChangePwd = async () => {
if (!oldPwd || !newPwd) { setPwdErr('请填写完整'); return; }
setPwdErr('');
try {
await authApi.changePassword(oldPwd, newPwd);
alert('修改成功,请重新登录');
setPwdOpen(false);
setOldPwd(''); setNewPwd('');
logout();
} catch (e) {
setPwdErr(errMsg(e));
}
};
return (
<div className="min-h-screen flex flex-col md:flex-row bg-gray-50">
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-black text-white">
<div className="font-bold">🕒 管理台</div>
<button className="text-2xl" onClick={() => setMenuOpen(v => !v)}>{menuOpen ? '✕' : '☰'}</button>
</header>
<aside className={`${menuOpen ? 'block' : 'hidden'} md:block w-full md:w-56 shrink-0 bg-white md:min-h-screen border-b-2 md:border-b-0 md:border-r-2 border-black`}>
<div className="hidden md:block p-4 border-b-2 border-black bg-gray-50">
<div className="font-black text-lg tracking-wide">🕒 义工管理</div>
</div>
<nav className="p-2 space-y-1">
{menu.map((m) => (
<NavLink
key={m.path}
to={m.path}
onClick={() => setMenuOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 text-sm font-medium border-2 ${isActive ? 'bg-black text-white border-black' : 'text-black border-transparent hover:bg-gray-100'}`
}
>
<span className="text-lg">{m.icon}</span>{m.label}
</NavLink>
))}
</nav>
<div className="md:absolute md:bottom-0 md:left-0 md:w-56 p-3 border-t-2 border-gray-200 bg-white">
<div className="text-xs text-gray-500 mb-2">当前管理员:<span className="font-bold text-black">{username}</span></div>
<div className="flex gap-2 flex-wrap">
<Button size="sm" variant="secondary" className="flex-1" onClick={() => setPwdOpen(true)}>改密码</Button>
<Button size="sm" variant="danger" className="flex-1" onClick={logout}>退出</Button>
</div>
</div>
</aside>
<main className="flex-1 p-4 md:p-6 min-h-screen">
<Outlet />
</main>
<Modal open={pwdOpen} title="修改密码" onClose={() => setPwdOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setPwdOpen(false)}>取消</Button>
<Button onClick={doChangePwd}>确认修改</Button>
</>
}>
<div className="space-y-4 max-w-sm">
<div><label className="block text-sm font-bold mb-1.5">原密码</label>
<Input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} />
</div>
<div><label className="block text-sm font-bold mb-1.5">新密码(至少 6 位)</label>
<Input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
</div>
{pwdErr && <div className="text-sm text-red-600">{pwdErr}</div>}
</div>
</Modal>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Input from '../../components/Input';
import Button from '../../components/Button';
import { authApi, errMsg } from '../../api';
export default function Login() {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
setError('请输入用户名和密码');
return;
}
setLoading(true);
setError('');
try {
const r = await authApi.login(username, password);
localStorage.setItem('volunteer_admin_token', r.token);
localStorage.setItem('volunteer_admin_username', r.username);
navigate('/admin/activities');
} catch (e) {
setError(errMsg(e, '登录失败'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<form onSubmit={submit} className="w-full max-w-md bg-white border-2 border-black p-6 md:p-8">
<div className="text-center mb-6">
<div className="text-4xl mb-2">🕒</div>
<h1 className="text-2xl font-black tracking-wide">义工时长管理系统</h1>
<p className="text-gray-500 text-sm mt-1">管理台登录</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold mb-1.5">用户名</label>
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
</div>
<div>
<label className="block text-sm font-bold mb-1.5">密码</label>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<Button type="submit" size="lg" className="w-full" disabled={loading}>
{loading ? '登录中...' : '登 录'}
</Button>
</div>
<div className="mt-6 text-center text-sm text-gray-500">
<p>默认账号:admin / admin123</p>
<a href="/u" className="inline-block mt-2 underline underline-offset-2 hover:text-black">← 返回用户端</a>
</div>
</form>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import Button from '../../components/Button';
import Input from '../../components/Input';
import Select from '../../components/Select';
import DataTable from '../../components/DataTable';
import Modal from '../../components/Modal';
import { groupsApi, usersApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export';
import type { UserItem, UserDetail, WorkGroup } from '../../types';
type Form = { class: string; name: string; volunteer_no: string; group_id: number | null };
const emptyForm: Form = { class: '', name: '', volunteer_no: '', group_id: null };
export default function Users() {
const [list, setList] = useState<UserItem[]>([]);
const [groups, setGroups] = useState<WorkGroup[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<Form>(emptyForm);
const [editId, setEditId] = useState<number | null>(null);
const [error, setError] = useState('');
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<UserDetail | null>(null);
const load = async () => {
setLoading(true);
try {
const [u, g] = await Promise.all([usersApi.list(), groupsApi.list()]);
setList(u);
setGroups(g);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const filtered = list.filter(u => {
if (!keyword.trim()) return true;
const k = keyword.trim().toLowerCase();
return [u.name, u.class, u.volunteer_no, u.group_name || ''].some(s => String(s || '').toLowerCase().includes(k));
});
const openNew = () => { setForm(emptyForm); setEditId(null); setError(''); setModalOpen(true); };
const openEdit = (u: UserItem) => {
setForm({ class: u.class, name: u.name, volunteer_no: u.volunteer_no || '', group_id: u.group_id });
setEditId(u.id); setError(''); setModalOpen(true);
};
const onDelete = (u: UserItem) => {
if (!confirm(`确认删除部员「${u.class} ${u.name}」?其所有时长登记也会一并删除。`)) return;
usersApi.remove(u.id).then(load).catch(e => alert(errMsg(e)));
};
const openDetail = async (u: UserItem) => {
try { const d = await usersApi.detail(u.id); setDetail(d); setDetailOpen(true); } catch (e) { alert(errMsg(e)); }
};
const submit = async () => {
if (!form.class.trim() || !form.name.trim()) { setError('班级与姓名不能为空'); return; }
try {
const payload = { ...form, volunteer_no: form.volunteer_no.trim() || null, group_id: form.group_id || null };
if (editId) await usersApi.update(editId, payload);
else await usersApi.create(payload);
setModalOpen(false);
load();
} catch (e) { setError(errMsg(e)); }
};
return (
<div>
<div className="mb-5">
<h1 className="text-xl md:text-2xl font-black mb-1">👥 用户管理</h1>
<p className="text-sm text-gray-500">管理部员信息,查看个人累计义工时长与参与活动明细</p>
</div>
<div className="bg-white border-2 border-black p-4 mb-4 flex flex-col md:flex-row gap-3 md:items-center md:justify-between">
<Input placeholder="搜索 姓名/班级/义工号/工作组..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="md:max-w-md" />
<Button onClick={openNew}>+ 新增部员</Button>
</div>
<DataTable<UserItem>
columns={[
{ key: 'class', title: '班级', width: '120px', render: (r) => <span className="whitespace-nowrap">{r.class}</span> },
{ key: 'name', title: '姓名', render: (r) => <span className="font-bold">{r.name}</span> },
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
{ key: 'group_name', title: '所属工作组', render: (r) => r.group_name || '—' },
{ key: 'total_hours', title: '累计时长(小时)', render: (r) => <span className="font-black">{Number(r.total_hours || 0).toFixed(2)}</span> },
{
key: 'op', title: '操作', width: '220px', render: (r) => (
<div className="flex gap-1.5 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => openDetail(r)}>详情</Button>
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}>编辑</Button>
<Button size="sm" variant="danger" onClick={() => onDelete(r)}>删除</Button>
</div>
)
}
]}
data={filtered}
rowKey={(r) => r.id}
/>
<Modal open={modalOpen} title={editId ? '编辑部员' : '新增部员'} onClose={() => setModalOpen(false)}
footer={<><Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button><Button onClick={submit}>{editId ? '保存修改' : '添加部员'}</Button></>}>
<div className="space-y-4 max-w-md">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-bold mb-1.5">班级 *</label>
<Input value={form.class} onChange={(e) => setForm({ ...form, class: e.target.value })} placeholder="如:高三(1)班" />
</div>
<div><label className="block text-sm font-bold mb-1.5">姓名 *</label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
</div>
<div><label className="block text-sm font-bold mb-1.5">义工号</label>
<Input value={form.volunteer_no} onChange={(e) => setForm({ ...form, volunteer_no: e.target.value })} />
</div>
<div><label className="block text-sm font-bold mb-1.5">所属工作组</label>
<Select value={String(form.group_id || '')} onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">-- 不设置 --</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</Select>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
</div>
</Modal>
<Modal open={detailOpen} title="部员详情" onClose={() => setDetailOpen(false)} width="max-w-3xl"
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}>
{detail && (
<div className="space-y-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 border-2 border-gray-200 p-3">
<div className="text-xs text-gray-500 mb-1">姓名</div><div className="font-black text-lg">{detail.name}</div>
</div>
<div className="bg-gray-50 border-2 border-gray-200 p-3">
<div className="text-xs text-gray-500 mb-1">班级</div><div className="font-bold">{detail.class}</div>
</div>
<div className="bg-gray-50 border-2 border-gray-200 p-3">
<div className="text-xs text-gray-500 mb-1">义工号</div><div className="font-bold">{detail.volunteer_no || '—'}</div>
</div>
<div className="bg-gray-50 border-2 border-gray-200 p-3">
<div className="text-xs text-gray-500 mb-1">累计时长</div><div className="font-black text-lg text-black">{Number(detail.total_hours || 0).toFixed(2)}h</div>
</div>
</div>
<div>
<h3 className="font-bold mb-2">参与的活动({detail.activities.length} 次)</h3>
<DataTable columns={[
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium">{r.title}</span> },
{ key: 'time', title: '时间', render: (r: any) => formatDateRange(r.start_time, r.end_time) },
{ key: 'hours', title: '时长', render: (r: any) => <span className="font-bold">{Number(r.hours || 0).toFixed(2)}h</span> },
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
]} data={detail.activities} rowKey={(r: any) => r.id} />
</div>
</div>
)}
</Modal>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Button from '../../components/Button';
import DataTable from '../../components/DataTable';
import { usersApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export';
import type { UserDetail } from '../../types';
export default function UserDashboard() {
const { id } = useParams();
const navigate = useNavigate();
const [data, setData] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!id) return;
setLoading(true);
usersApi.detail(Number(id)).then(d => {
setData(d);
setLoading(false);
}).catch(e => {
setError(errMsg(e, '加载失败'));
setLoading(false);
});
}, [id]);
if (loading) return <div className="p-8 text-center text-gray-500">加载中...</div>;
if (error) return <div className="p-8 text-center"><p className="text-red-600 mb-4">{error}</p><Button onClick={() => navigate('/u')}>返回查询</Button></div>;
if (!data) return null;
return (
<div className="min-h-screen bg-gray-50 px-4 py-6 md:py-10">
<div className="max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-5 flex-wrap gap-2">
<h1 className="text-xl md:text-2xl font-black tracking-wide">🕒 个人义工时长总览</h1>
<Button variant="secondary" onClick={() => navigate('/u')}>← 返回查询</Button>
</div>
<div className="bg-white border-2 border-black p-5 mb-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<div className="text-xs text-gray-500 mb-1">姓名</div>
<div className="text-lg font-bold">{data.name}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">班级</div>
<div className="text-lg font-bold">{data.class || '—'}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">义工号</div>
<div className="text-lg font-bold">{data.volunteer_no || '—'}</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">所属工作组</div>
<div className="text-lg font-bold">{data.group_name || '—'}</div>
</div>
</div>
<div className="mt-5 pt-5 border-t-2 border-gray-200 grid grid-cols-2 gap-4">
<div className="bg-gray-50 border-2 border-gray-200 p-4">
<div className="text-xs text-gray-500 mb-1">累计义工时长</div>
<div className="text-2xl md:text-3xl font-black text-black">{Number(data.total_hours || 0).toFixed(2)} <span className="text-base font-bold text-gray-600">小时</span></div>
</div>
<div className="bg-gray-50 border-2 border-gray-200 p-4">
<div className="text-xs text-gray-500 mb-1">参与活动数</div>
<div className="text-2xl md:text-3xl font-black text-black">{data.activities.length} <span className="text-base font-bold text-gray-600">次</span></div>
</div>
</div>
</div>
<h2 className="font-bold text-lg mb-3">参与活动明细</h2>
<DataTable
id="user-activities-table"
columns={[
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium">{r.title}</span> },
{ key: 'time', title: '活动时间', render: (r: any) => formatDateRange(r.start_time, r.end_time) },
{ key: 'hours', title: '时长(小时)', render: (r: any) => <span className="font-bold">{Number(r.hours || 0).toFixed(2)}</span> },
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
]}
data={data.activities}
rowKey={(r: any) => r.id}
emptyText="暂无参与的活动记录"
/>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Input from '../../components/Input';
import Button from '../../components/Button';
import DataTable from '../../components/DataTable';
import { usersApi, errMsg } from '../../api';
import type { UserItem } from '../../types';
export default function UserIndex() {
const [name, setName] = useState('');
const [results, setResults] = useState<UserItem[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const doSearch = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!name.trim()) return;
setLoading(true);
setError('');
try {
const list = await usersApi.search(name.trim());
setResults(list);
} catch (e) {
setError(errMsg(e, '查询失败'));
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col items-center bg-gray-50 px-4 py-8 md:py-16">
<div className="w-full max-w-xl">
<div className="mb-8 text-center">
<div className="text-4xl md:text-5xl mb-3">🕒</div>
<h1 className="text-2xl md:text-3xl font-black text-black tracking-wide">义工时长查询</h1>
<p className="text-gray-500 mt-2 text-sm md:text-base">请输入您的姓名查询个人义工时长</p>
<div className="mt-4 flex justify-center gap-4 text-sm">
<a href="/admin/login" className="text-gray-500 hover:text-black underline underline-offset-2">管理入口</a>
</div>
</div>
<form onSubmit={doSearch} className="bg-white border-2 border-black p-4 md:p-6">
<label className="block text-sm font-bold mb-2">姓名</label>
<div className="flex gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名(支持模糊搜索)"
className="flex-1"
autoFocus
/>
<Button type="submit" disabled={loading}>{loading ? '查询中' : '查询'}</Button>
</div>
{error && <div className="mt-3 text-sm text-red-600">{error}</div>}
</form>
{results !== null && (
<div className="mt-6">
<h2 className="font-bold mb-2 text-black">
查询结果:{results.length} 人
{results.length === 0 && ' (未找到匹配的部员,请确认姓名是否正确)'}
</h2>
{results.length === 1 ? (
<div className="bg-white border-2 border-black p-4">
<div className="flex justify-between items-center flex-wrap gap-3">
<div>
<div className="font-bold text-lg">{results[0].name}</div>
<div className="text-sm text-gray-600 mt-1">
班级:{results[0].class || '—'} &nbsp; 义工号:{results[0].volunteer_no || '—'}
</div>
{results[0].group_name && <div className="text-sm text-gray-600 mt-0.5">工作组:{results[0].group_name}</div>}
</div>
<Button onClick={() => navigate(`/u/${results[0].id}`)}>查看详情</Button>
</div>
</div>
) : results.length > 1 ? (
<DataTable<UserItem>
columns={[
{ key: 'name', title: '姓名', render: (r) => <span className="font-bold">{r.name}</span> },
{ key: 'class', title: '班级' },
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
{ key: 'group_name', title: '工作组', render: (r) => r.group_name || '—' },
{
key: 'op', title: '操作', render: (r) => (
<Button size="sm" onClick={() => navigate(`/u/${r.id}`)}>进入</Button>
)
}
]}
data={results}
rowKey={(r) => r.id}
emptyText=""
/>
) : null}
</div>
)}
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
export interface WorkGroup {
id: number;
name: string;
}
export interface UserItem {
id: number;
class: string;
name: string;
volunteer_no: string | null;
group_id: number | null;
group_name?: string | null;
total_hours?: number;
}
export interface UserDetail extends UserItem {
activities: ActivityHours[];
}
export interface ActivityHours {
id: number;
title: string;
start_time: string;
end_time: string;
hours: number;
remark?: string;
}
export interface ActivityItem {
id: number;
title: string;
content: string;
start_time: string;
end_time: string;
created_at: string;
groups: number[];
groups_name_list: string[];
groups_name?: string;
}
export interface Participant {
id: number;
user_id?: number;
class: string;
name: string;
volunteer_no: string | null;
group_name: string | null;
hours: number;
remark: string;
hours_id: number | null;
}
export interface HoursRow {
id: number;
hours: number;
remark: string;
created_at: string;
user_id: number;
class: string;
name: string;
volunteer_no: string | null;
group_name: string | null;
}
export interface HoursActivityResult {
activity: ActivityItem;
list: HoursRow[];
total_hours: number;
total_people: number;
}
export interface UserTotal {
id: number;
class: string;
name: string;
volunteer_no: string | null;
group_name: string | null;
total_hours: number;
activity_count: number;
}
export interface UserActivities {
id: number;
class: string;
name: string;
volunteer_no: string | null;
group_name: string | null;
activities: ActivityHours[];
}
export interface ExportResult {
users?: UserItem[];
activities?: ActivityItem[];
hours?: any[];
user_totals?: UserTotal[];
user_activities?: UserActivities[];
}
+44
View File
@@ -0,0 +1,44 @@
import * as XLSX from 'xlsx';
import html2canvas from 'html2canvas';
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function exportToExcel(sheets: Array<{ name: string; data: any[] }>, filename: string) {
const wb = XLSX.utils.book_new();
for (const s of sheets) {
const ws = XLSX.utils.json_to_sheet(s.data || []);
const colWidths = Object.keys(s.data[0] || {}).map(k => ({ wch: Math.max(8, String(k).length + 2) }));
ws['!cols'] = colWidths;
XLSX.utils.book_append_sheet(wb, ws, s.name.slice(0, 31));
}
const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
downloadBlob(new Blob([buf], { type: 'application/octet-stream' }), filename);
}
export async function exportTableToImage(el: HTMLElement, filename: string) {
const canvas = await html2canvas(el, {
backgroundColor: '#FFFFFF',
useCORS: true,
scale: 2,
logging: false,
});
return new Promise<void>((resolve) => {
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, filename);
resolve();
}, 'image/png');
});
}
export function formatDateRange(s: string, e: string) {
const a = s?.replace('T', ' ')?.slice(0, 16);
const b = e?.replace('T', ' ')?.slice(0, 16);
return `${a || ''} ~ ${b || ''}`;
}
+52
View File
@@ -0,0 +1,52 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
borderRadius: {
DEFAULT: '0px',
none: '0px',
sm: '0px',
md: '0px',
lg: '0px',
xl: '0px',
'2xl': '0px',
full: '9999px',
},
colors: {
black: '#111111',
white: '#FFFFFF',
gray: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#E5E5E5',
300: '#D4D4D4',
400: '#A3A3A3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
},
blue: {
50: '#EFF6FF',
100: '#DBEAFE',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
},
red: {
500: '#EF4444',
600: '#DC2626',
},
green: {
500: '#22C55E',
600: '#16A34A',
},
},
extend: {},
},
plugins: [],
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/index.ts","./src/components/button.tsx","./src/components/datatable.tsx","./src/components/input.tsx","./src/components/modal.tsx","./src/components/select.tsx","./src/components/textarea.tsx","./src/pages/admin/activities.tsx","./src/pages/admin/export.tsx","./src/pages/admin/hours.tsx","./src/pages/admin/layout.tsx","./src/pages/admin/login.tsx","./src/pages/admin/users.tsx","./src/pages/user/dashboard.tsx","./src/pages/user/index.tsx","./src/types/index.ts","./src/utils/export.ts"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});
+14
View File
@@ -0,0 +1,14 @@
services:
volunteer:
build: .
image: volunteer-manage:latest
container_name: volunteer-manage
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./server/data:/app/server/data
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-volunteer-manage-secret-key-2026}
+1905
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "volunteer-server",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc -p tsconfig.json && node -e \"require('fs').mkdirSync('dist/db',{recursive:true}); require('fs').copyFileSync('src/db/schema.sql','dist/db/schema.sql')\"",
"start": "node dist/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"sql.js": "^1.11.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/sql.js": "^1.4.9",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.7.4",
"ts-node-dev": "^2.0.0",
"typescript": "^5.6.2"
}
}
+3
View File
@@ -0,0 +1,3 @@
export const JWT_SECRET = process.env.JWT_SECRET || 'volunteer-manage-secret-key-2026';
export const JWT_EXPIRES_IN = '7d';
export const PORT = Number(process.env.PORT) || 3001;
+123
View File
@@ -0,0 +1,123 @@
import initSqlJs, { SqlJsStatic, Database } from 'sql.js';
import path from 'path';
import fs from 'fs';
import bcrypt from 'bcryptjs';
let SQL: SqlJsStatic | null = null;
let db: (Database & {
prepareCompat(sql: string): StmtCompat;
execCompat(sql: string): void;
pragmaCompat(expr: string): void;
save(): void;
}) | null = null;
let dbFilePath: string = '';
interface StmtCompat {
run(...params: any[]): { changes: number; lastInsertRowid: number };
get(...params: any[]): any;
all(...params: any[]): any[];
}
function wrapStmt(db: Database & any, sql: string): StmtCompat {
const bindArray = (params: any[]): any[] => {
return params.map(p => {
if (p === undefined) return null;
return p;
});
};
return {
run(...params) {
const stmt = db.prepare(sql);
try {
stmt.bind(bindArray(params));
stmt.step();
const changes = db.getRowsModified();
const lastInsertRowid = (db as any).lastInsertRowid?.() ?? 0;
return { changes, lastInsertRowid };
} finally {
stmt.free();
}
},
get(...params) {
const stmt = db.prepare(sql);
try {
stmt.bind(bindArray(params));
if (stmt.step()) return stmt.getAsObject();
return undefined;
} finally {
stmt.free();
}
},
all(...params) {
const stmt = db.prepare(sql);
const rows: any[] = [];
try {
stmt.bind(bindArray(params));
while (stmt.step()) rows.push(stmt.getAsObject());
return rows;
} finally {
stmt.free();
}
},
};
}
export async function initDb() {
if (db) return db;
SQL = await initSqlJs({
locateFile: (file) => {
const p = path.join(__dirname, '..', '..', 'node_modules', 'sql.js', 'dist', file);
if (fs.existsSync(p)) return p;
return path.join(require.resolve('sql.js').replace(/index\.js$/, 'dist'), file);
},
});
const dataDir = path.join(__dirname, '..', '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
dbFilePath = path.join(dataDir, 'volunteer.db');
let rawDb: Database;
if (fs.existsSync(dbFilePath)) {
const buf = fs.readFileSync(dbFilePath);
rawDb = new SQL.Database(new Uint8Array(buf));
} else {
rawDb = new SQL.Database();
}
const extended = rawDb as any;
extended.prepareCompat = function (sql: string) { return wrapStmt(extended, sql); };
extended.execCompat = function (sql: string) { extended.exec(sql); };
extended.pragmaCompat = function (expr: string) {
if (/foreign_keys\s*=\s*ON/i.test(expr)) {
// sql.js 默认开启 FK?尝试显式设置
try { extended.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
};
extended.save = function () {
const data = extended.export();
fs.writeFileSync(dbFilePath, Buffer.from(data));
};
db = extended;
const d = extended;
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf-8');
try { d.execCompat(schema); d.save(); } catch (e) { console.error('Schema init error', e); }
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
if (!adminCount || adminCount.c === 0) {
const hash = bcrypt.hashSync('admin123', 10);
d.prepareCompat('INSERT INTO admins (username, password_hash) VALUES (?, ?)').run('admin', hash);
d.save();
}
return db;
}
export function getDb() {
if (!db) throw new Error('DB not initialized, call initDb() first');
return db;
}
export type DbInstance = NonNullable<typeof db>;
+50
View File
@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS work_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class TEXT NOT NULL,
name TEXT NOT NULL,
volunteer_no TEXT,
group_id INTEGER,
FOREIGN KEY (group_id) REFERENCES work_groups(id) ON DELETE SET NULL,
UNIQUE(class, name)
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS activity_groups (
activity_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (activity_id, group_id),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES work_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS hours (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
hours REAL NOT NULL DEFAULT 0,
remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(activity_id, user_id)
);
CREATE TABLE IF NOT EXISTS admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
+43
View File
@@ -0,0 +1,43 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { initDb } from './db';
import { PORT } from './config';
import authRoutes from './routes/auth';
import groupsRoutes from './routes/groups';
import usersRoutes from './routes/users';
import activitiesRoutes from './routes/activities';
import hoursRoutes from './routes/hours';
import exportRoutes from './routes/export';
async function bootstrap() {
await initDb();
const app = express();
app.use(cors());
app.use(express.json({ limit: '5mb' }));
app.use(express.urlencoded({ extended: true }));
app.use('/api/auth', authRoutes);
app.use('/api/groups', groupsRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/activities', activitiesRoutes);
app.use('/api/hours', hoursRoutes);
app.use('/api/export', exportRoutes);
const staticPath = path.join(__dirname, '..', '..', 'client', 'dist');
app.use(express.static(staticPath));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/')) return next();
res.sendFile(path.join(staticPath, 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Volunteer system server running on http://0.0.0.0:${PORT}`);
});
}
bootstrap().catch(err => {
console.error('Failed to start server:', err);
process.exit(1);
});
+24
View File
@@ -0,0 +1,24 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config';
export interface AuthRequest extends Request {
adminId?: number;
adminUsername?: string;
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: '未登录或登录已过期' });
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET) as { id: number; username: string };
req.adminId = payload.id;
req.adminUsername = payload.username;
next();
} catch {
return res.status(401).json({ error: 'Token 无效或已过期' });
}
}
+134
View File
@@ -0,0 +1,134 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', (req: Request, res: Response) => {
const db = getDb();
const list = db.prepareCompat(`
SELECT a.*,
GROUP_CONCAT(g.name) AS groups_name,
GROUP_CONCAT(g.id) AS groups_id
FROM activities a
LEFT JOIN activity_groups ag ON a.id = ag.activity_id
LEFT JOIN work_groups g ON ag.group_id = g.id
GROUP BY a.id
ORDER BY a.start_time DESC
`).all().map((item: any) => ({
...item,
groups: (item.groups_id ? String(item.groups_id).split(',').map(Number) : []),
groups_name_list: (item.groups_name ? String(item.groups_name).split(',') : []),
}));
res.json(list);
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const act = db.prepareCompat('SELECT * FROM activities WHERE id = ?').get(id) as any;
if (!act) return res.status(404).json({ error: '活动不存在' });
const groups = db.prepareCompat(`
SELECT g.id, g.name FROM activity_groups ag
JOIN work_groups g ON ag.group_id = g.id
WHERE ag.activity_id = ?
`).all(id);
res.json({ ...act, groups: groups.map((g: any) => g.id), groups_name_list: groups.map((g: any) => g.name) });
});
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { title, content, start_time, end_time, groups } = req.body || {};
if (!title || !String(title).trim()) return res.status(400).json({ error: '活动标题不能为空' });
if (!start_time || !end_time) return res.status(400).json({ error: '活动时间不能为空' });
const db = getDb();
try {
db.execCompat('BEGIN');
const info = db.prepareCompat('INSERT INTO activities (title, content, start_time, end_time) VALUES (?,?,?,?)')
.run(String(title).trim(), content || '', start_time, end_time);
const activityId = Number(info.lastInsertRowid);
const insertAG = db.prepareCompat('INSERT OR IGNORE INTO activity_groups (activity_id, group_id) VALUES (?,?)');
if (Array.isArray(groups)) {
for (const gid of groups) insertAG.run(activityId, Number(gid));
}
db.execCompat('COMMIT');
db.save();
res.json({ id: activityId });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e.message });
}
});
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { title, content, start_time, end_time, groups } = req.body || {};
if (!title || !String(title).trim()) return res.status(400).json({ error: '活动标题不能为空' });
if (!start_time || !end_time) return res.status(400).json({ error: '活动时间不能为空' });
const db = getDb();
try {
db.execCompat('BEGIN');
db.prepareCompat('UPDATE activities SET title=?, content=?, start_time=?, end_time=? WHERE id=?')
.run(String(title).trim(), content || '', start_time, end_time, id);
db.prepareCompat('DELETE FROM activity_groups WHERE activity_id = ?').run(id);
const insertAG = db.prepareCompat('INSERT OR IGNORE INTO activity_groups (activity_id, group_id) VALUES (?,?)');
if (Array.isArray(groups)) {
for (const gid of groups) insertAG.run(id, Number(gid));
}
db.execCompat('COMMIT');
db.save();
res.json({ ok: true });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e.message });
}
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
db.prepareCompat('DELETE FROM activities WHERE id = ?').run(id);
db.save();
res.json({ ok: true });
});
router.get('/:id/participants', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const act = db.prepareCompat('SELECT * FROM activities WHERE id = ?').get(id);
if (!act) return res.status(404).json({ error: '活动不存在' });
const groupIds = db.prepareCompat('SELECT group_id FROM activity_groups WHERE activity_id = ?').all(id).map((r: any) => r.group_id);
let users: any[];
if (groupIds.length === 0) {
users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class, u.name
`).all();
} else {
const ph = groupIds.map(() => '?').join(',');
users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.group_id IN (${ph}) OR u.group_id IS NULL
ORDER BY u.class, u.name
`).all(...groupIds);
}
const hoursRows = db.prepareCompat('SELECT * FROM hours WHERE activity_id = ?').all(id);
const hoursMap: Record<number, any> = {};
for (const h of hoursRows) hoursMap[(h as any).user_id] = h;
const list = users.map((u: any) => ({
...u,
hours: hoursMap[u.id]?.hours ?? 0,
remark: hoursMap[u.id]?.remark ?? '',
hours_id: hoursMap[u.id]?.id ?? null,
}));
res.json({ activity: act, list });
});
export default router;
+42
View File
@@ -0,0 +1,42 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { getDb } from '../db';
import { JWT_SECRET, JWT_EXPIRES_IN } from '../config';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: '请输入用户名和密码' });
}
const db = getDb();
const admin = db.prepareCompat('SELECT * FROM admins WHERE username = ?').get(username) as any;
if (!admin) return res.status(401).json({ error: '用户名或密码错误' });
const ok = bcrypt.compareSync(password, admin.password_hash);
if (!ok) return res.status(401).json({ error: '用户名或密码错误' });
const token = jwt.sign({ id: admin.id, username: admin.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({ token, username: admin.username });
});
router.post('/change-password', authMiddleware, (req: AuthRequest, res: Response) => {
const { oldPassword, newPassword } = req.body || {};
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: '缺少参数' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: '新密码至少 6 位' });
}
const db = getDb();
const admin = db.prepareCompat('SELECT * FROM admins WHERE id = ?').get(req.adminId) as any;
const ok = bcrypt.compareSync(oldPassword, admin.password_hash);
if (!ok) return res.status(400).json({ error: '原密码错误' });
const hash = bcrypt.hashSync(newPassword, 10);
db.prepareCompat('UPDATE admins SET password_hash = ? WHERE id = ?').run(hash, req.adminId);
db.save();
res.json({ ok: true });
});
export default router;
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.post('/', authMiddleware, (req: AuthRequest, res) => {
const { include_users, include_activities, include_hours, include_user_totals, include_user_activities } = req.body || {};
const db = getDb();
const result: any = {};
if (include_users) {
result.users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class, u.name
`).all();
}
if (include_activities) {
result.activities = db.prepareCompat(`
SELECT a.*, GROUP_CONCAT(g.name) AS groups_name
FROM activities a
LEFT JOIN activity_groups ag ON a.id = ag.activity_id
LEFT JOIN work_groups g ON ag.group_id = g.id
GROUP BY a.id
ORDER BY a.start_time DESC
`).all();
}
if (include_hours) {
result.hours = db.prepareCompat(`
SELECT h.hours, h.remark, h.created_at,
a.title AS activity_title, a.start_time, a.end_time,
u.class, u.name, u.volunteer_no, g.name AS group_name
FROM hours h
JOIN activities a ON h.activity_id = a.id
JOIN users u ON h.user_id = u.id
LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY a.start_time DESC, u.class, u.name
`).all();
}
if (include_user_totals) {
result.user_totals = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name,
COALESCE(SUM(h.hours), 0) AS total_hours,
COUNT(DISTINCT h.activity_id) AS activity_count
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
LEFT JOIN hours h ON h.user_id = u.id
GROUP BY u.id
ORDER BY total_hours DESC, u.class, u.name
`).all();
}
if (include_user_activities) {
const users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class, u.name
`).all() as any[];
result.user_activities = users.map((u) => {
const acts = db.prepareCompat(`
SELECT a.title, a.start_time, a.end_time, h.hours, h.remark
FROM hours h JOIN activities a ON h.activity_id = a.id
WHERE h.user_id = ? ORDER BY a.start_time DESC
`).all(u.id);
return { ...u, activities: acts };
});
}
res.json(result);
});
export default router;
+58
View File
@@ -0,0 +1,58 @@
import { Router, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', (req, res: Response) => {
const db = getDb();
const list = db.prepareCompat('SELECT * FROM work_groups ORDER BY id ASC').all();
res.json(list);
});
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { name } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ error: '工作组名称不能为空' });
}
const db = getDb();
try {
const info = db.prepareCompat('INSERT INTO work_groups (name) VALUES (?)').run(String(name).trim());
db.save();
res.json({ id: info.lastInsertRowid, name });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '工作组名称已存在' });
}
res.status(500).json({ error: e.message });
}
});
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { name } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ error: '工作组名称不能为空' });
}
const db = getDb();
try {
db.prepareCompat('UPDATE work_groups SET name = ? WHERE id = ?').run(String(name).trim(), id);
db.save();
res.json({ id, name });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '工作组名称已存在' });
}
res.status(500).json({ error: e.message });
}
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
db.prepareCompat('DELETE FROM work_groups WHERE id = ?').run(id);
db.save();
res.json({ ok: true });
});
export default router;
+58
View File
@@ -0,0 +1,58 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
const { activity_id, items } = req.body || {};
if (!activity_id || !Array.isArray(items)) {
return res.status(400).json({ error: '参数错误' });
}
const db = getDb();
try {
db.execCompat('BEGIN');
const upsert = db.prepareCompat(`
INSERT INTO hours (activity_id, user_id, hours, remark)
VALUES (?,?,?,?)
ON CONFLICT(activity_id, user_id) DO UPDATE SET hours=excluded.hours, remark=excluded.remark
`);
const delZero = db.prepareCompat('DELETE FROM hours WHERE activity_id = ? AND user_id = ? AND hours = 0 AND (remark IS NULL OR remark = \'\')');
for (const it of items) {
const h = Number(it.hours) || 0;
const r = (it.remark ?? '').toString();
if (h === 0 && !r) {
delZero.run(activity_id, it.user_id);
} else {
upsert.run(activity_id, it.user_id, h, r);
}
}
db.execCompat('COMMIT');
db.save();
res.json({ ok: true });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e.message });
}
});
router.get('/activity/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const act = db.prepareCompat('SELECT * FROM activities WHERE id = ?').get(id) as any;
if (!act) return res.status(404).json({ error: '活动不存在' });
const rows = db.prepareCompat(`
SELECT h.id, h.hours, h.remark, h.created_at,
u.id AS user_id, u.class, u.name, u.volunteer_no,
g.name AS group_name
FROM hours h
JOIN users u ON h.user_id = u.id
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE h.activity_id = ?
ORDER BY u.class, u.name
`).all(id);
const totalHours = rows.reduce((s: number, r: any) => s + (Number(r.hours) || 0), 0);
res.json({ activity: act, list: rows, total_hours: totalHours, total_people: rows.length });
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', authMiddleware, (req: AuthRequest, res: Response) => {
const db = getDb();
const list = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, u.group_id,
g.name AS group_name,
COALESCE((SELECT SUM(h.hours) FROM hours h WHERE h.user_id = u.id), 0) AS total_hours
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class ASC, u.name ASC
`).all();
res.json(list);
});
router.get('/search', (req: Request, res: Response) => {
const name = String(req.query.name || '').trim();
if (!name) return res.json([]);
const db = getDb();
const list = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.name LIKE ?
ORDER BY u.class ASC
`).all(`%${name}%`);
res.json(list);
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const user = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, u.group_id, g.name AS group_name,
COALESCE((SELECT SUM(h.hours) FROM hours h WHERE h.user_id = u.id), 0) AS total_hours
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.id = ?
`).get(id) as any;
if (!user) return res.status(404).json({ error: '部员不存在' });
const activities = db.prepareCompat(`
SELECT a.id, a.title, a.start_time, a.end_time, h.hours, h.remark
FROM hours h
JOIN activities a ON h.activity_id = a.id
WHERE h.user_id = ?
ORDER BY a.start_time DESC
`).all(id);
res.json({ ...user, activities });
});
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { class: cls, name, volunteer_no, group_id } = req.body || {};
if (!cls || !name || !String(cls).trim() || !String(name).trim()) {
return res.status(400).json({ error: '班级和姓名不能为空' });
}
const db = getDb();
try {
const info = db.prepareCompat('INSERT INTO users (class, name, volunteer_no, group_id) VALUES (?,?,?,?)')
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, group_id || null);
db.save();
res.json({ id: info.lastInsertRowid });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '该班级下已存在同名部员' });
}
res.status(500).json({ error: e.message });
}
});
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { class: cls, name, volunteer_no, group_id } = req.body || {};
if (!cls || !name || !String(cls).trim() || !String(name).trim()) {
return res.status(400).json({ error: '班级和姓名不能为空' });
}
const db = getDb();
try {
db.prepareCompat('UPDATE users SET class=?, name=?, volunteer_no=?, group_id=? WHERE id=?')
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, group_id || null, id);
db.save();
res.json({ ok: true });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '该班级下已存在同名部员' });
}
res.status(500).json({ error: e.message });
}
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
db.prepareCompat('DELETE FROM users WHERE id = ?').run(id);
db.save();
res.json({ ok: true });
});
export default router;
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "data"]
}