feat: 初始化义工时长管理系统
- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG) - 用户端:姓名查询个人义工时长与活动记录 - 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite) - 支持 Docker 部署,附带 Gitea Actions 自动部署工作流 - 响应式界面,兼容移动端与 PC 端 - 默认管理员账号:admin / admin123
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
server/data
|
||||||
|
*.db
|
||||||
|
npm-debug.log*
|
||||||
|
README.md
|
||||||
|
.trae
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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`。
|
||||||
@@ -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>
|
||||||
Generated
+3171
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 || '—'} 义工号:{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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 || ''}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
Generated
+1905
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>;
|
||||||
@@ -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'))
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 无效或已过期' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user