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

- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG)
- 用户端:姓名查询个人义工时长与活动记录
- 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite)
- 支持 Docker 部署,附带 Gitea Actions 自动部署工作流
- 响应式界面,兼容移动端与 PC 端
- 默认管理员账号:admin / admin123
This commit is contained in:
Trae Assistant
2026-10-04 18:06:24 +08:00
commit 25b2b8f381
50 changed files with 7918 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', (req: Request, res: Response) => {
const db = getDb();
const list = db.prepareCompat(`
SELECT a.*,
GROUP_CONCAT(g.name) AS groups_name,
GROUP_CONCAT(g.id) AS groups_id
FROM activities a
LEFT JOIN activity_groups ag ON a.id = ag.activity_id
LEFT JOIN work_groups g ON ag.group_id = g.id
GROUP BY a.id
ORDER BY a.start_time DESC
`).all().map((item: any) => ({
...item,
groups: (item.groups_id ? String(item.groups_id).split(',').map(Number) : []),
groups_name_list: (item.groups_name ? String(item.groups_name).split(',') : []),
}));
res.json(list);
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const act = db.prepareCompat('SELECT * FROM activities WHERE id = ?').get(id) as any;
if (!act) return res.status(404).json({ error: '活动不存在' });
const groups = db.prepareCompat(`
SELECT g.id, g.name FROM activity_groups ag
JOIN work_groups g ON ag.group_id = g.id
WHERE ag.activity_id = ?
`).all(id);
res.json({ ...act, groups: groups.map((g: any) => g.id), groups_name_list: groups.map((g: any) => g.name) });
});
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { title, content, start_time, end_time, groups } = req.body || {};
if (!title || !String(title).trim()) return res.status(400).json({ error: '活动标题不能为空' });
if (!start_time || !end_time) return res.status(400).json({ error: '活动时间不能为空' });
const db = getDb();
try {
db.execCompat('BEGIN');
const info = db.prepareCompat('INSERT INTO activities (title, content, start_time, end_time) VALUES (?,?,?,?)')
.run(String(title).trim(), content || '', start_time, end_time);
const activityId = Number(info.lastInsertRowid);
const insertAG = db.prepareCompat('INSERT OR IGNORE INTO activity_groups (activity_id, group_id) VALUES (?,?)');
if (Array.isArray(groups)) {
for (const gid of groups) insertAG.run(activityId, Number(gid));
}
db.execCompat('COMMIT');
db.save();
res.json({ id: activityId });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e.message });
}
});
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { title, content, start_time, end_time, groups } = req.body || {};
if (!title || !String(title).trim()) return res.status(400).json({ error: '活动标题不能为空' });
if (!start_time || !end_time) return res.status(400).json({ error: '活动时间不能为空' });
const db = getDb();
try {
db.execCompat('BEGIN');
db.prepareCompat('UPDATE activities SET title=?, content=?, start_time=?, end_time=? WHERE id=?')
.run(String(title).trim(), content || '', start_time, end_time, id);
db.prepareCompat('DELETE FROM activity_groups WHERE activity_id = ?').run(id);
const insertAG = db.prepareCompat('INSERT OR IGNORE INTO activity_groups (activity_id, group_id) VALUES (?,?)');
if (Array.isArray(groups)) {
for (const gid of groups) insertAG.run(id, Number(gid));
}
db.execCompat('COMMIT');
db.save();
res.json({ ok: true });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e.message });
}
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
db.prepareCompat('DELETE FROM activities WHERE id = ?').run(id);
db.save();
res.json({ ok: true });
});
router.get('/:id/participants', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const act = db.prepareCompat('SELECT * FROM activities WHERE id = ?').get(id);
if (!act) return res.status(404).json({ error: '活动不存在' });
const groupIds = db.prepareCompat('SELECT group_id FROM activity_groups WHERE activity_id = ?').all(id).map((r: any) => r.group_id);
let users: any[];
if (groupIds.length === 0) {
users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class, u.name
`).all();
} else {
const ph = groupIds.map(() => '?').join(',');
users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.group_id IN (${ph}) OR u.group_id IS NULL
ORDER BY u.class, u.name
`).all(...groupIds);
}
const hoursRows = db.prepareCompat('SELECT * FROM hours WHERE activity_id = ?').all(id);
const hoursMap: Record<number, any> = {};
for (const h of hoursRows) hoursMap[(h as any).user_id] = h;
const list = users.map((u: any) => ({
...u,
hours: hoursMap[u.id]?.hours ?? 0,
remark: hoursMap[u.id]?.remark ?? '',
hours_id: hoursMap[u.id]?.id ?? null,
}));
res.json({ activity: act, list });
});
export default router;
+42
View File
@@ -0,0 +1,42 @@
import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { getDb } from '../db';
import { JWT_SECRET, JWT_EXPIRES_IN } from '../config';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: '请输入用户名和密码' });
}
const db = getDb();
const admin = db.prepareCompat('SELECT * FROM admins WHERE username = ?').get(username) as any;
if (!admin) return res.status(401).json({ error: '用户名或密码错误' });
const ok = bcrypt.compareSync(password, admin.password_hash);
if (!ok) return res.status(401).json({ error: '用户名或密码错误' });
const token = jwt.sign({ id: admin.id, username: admin.username }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
res.json({ token, username: admin.username });
});
router.post('/change-password', authMiddleware, (req: AuthRequest, res: Response) => {
const { oldPassword, newPassword } = req.body || {};
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: '缺少参数' });
}
if (newPassword.length < 6) {
return res.status(400).json({ error: '新密码至少 6 位' });
}
const db = getDb();
const admin = db.prepareCompat('SELECT * FROM admins WHERE id = ?').get(req.adminId) as any;
const ok = bcrypt.compareSync(oldPassword, admin.password_hash);
if (!ok) return res.status(400).json({ error: '原密码错误' });
const hash = bcrypt.hashSync(newPassword, 10);
db.prepareCompat('UPDATE admins SET password_hash = ? WHERE id = ?').run(hash, req.adminId);
db.save();
res.json({ ok: true });
});
export default router;
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.post('/', authMiddleware, (req: AuthRequest, res) => {
const { include_users, include_activities, include_hours, include_user_totals, include_user_activities } = req.body || {};
const db = getDb();
const result: any = {};
if (include_users) {
result.users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class, u.name
`).all();
}
if (include_activities) {
result.activities = db.prepareCompat(`
SELECT a.*, GROUP_CONCAT(g.name) AS groups_name
FROM activities a
LEFT JOIN activity_groups ag ON a.id = ag.activity_id
LEFT JOIN work_groups g ON ag.group_id = g.id
GROUP BY a.id
ORDER BY a.start_time DESC
`).all();
}
if (include_hours) {
result.hours = db.prepareCompat(`
SELECT h.hours, h.remark, h.created_at,
a.title AS activity_title, a.start_time, a.end_time,
u.class, u.name, u.volunteer_no, g.name AS group_name
FROM hours h
JOIN activities a ON h.activity_id = a.id
JOIN users u ON h.user_id = u.id
LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY a.start_time DESC, u.class, u.name
`).all();
}
if (include_user_totals) {
result.user_totals = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name,
COALESCE(SUM(h.hours), 0) AS total_hours,
COUNT(DISTINCT h.activity_id) AS activity_count
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
LEFT JOIN hours h ON h.user_id = u.id
GROUP BY u.id
ORDER BY total_hours DESC, u.class, u.name
`).all();
}
if (include_user_activities) {
const users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class, u.name
`).all() as any[];
result.user_activities = users.map((u) => {
const acts = db.prepareCompat(`
SELECT a.title, a.start_time, a.end_time, h.hours, h.remark
FROM hours h JOIN activities a ON h.activity_id = a.id
WHERE h.user_id = ? ORDER BY a.start_time DESC
`).all(u.id);
return { ...u, activities: acts };
});
}
res.json(result);
});
export default router;
+58
View File
@@ -0,0 +1,58 @@
import { Router, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', (req, res: Response) => {
const db = getDb();
const list = db.prepareCompat('SELECT * FROM work_groups ORDER BY id ASC').all();
res.json(list);
});
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { name } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ error: '工作组名称不能为空' });
}
const db = getDb();
try {
const info = db.prepareCompat('INSERT INTO work_groups (name) VALUES (?)').run(String(name).trim());
db.save();
res.json({ id: info.lastInsertRowid, name });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '工作组名称已存在' });
}
res.status(500).json({ error: e.message });
}
});
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { name } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ error: '工作组名称不能为空' });
}
const db = getDb();
try {
db.prepareCompat('UPDATE work_groups SET name = ? WHERE id = ?').run(String(name).trim(), id);
db.save();
res.json({ id, name });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '工作组名称已存在' });
}
res.status(500).json({ error: e.message });
}
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
db.prepareCompat('DELETE FROM work_groups WHERE id = ?').run(id);
db.save();
res.json({ ok: true });
});
export default router;
+58
View File
@@ -0,0 +1,58 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
const { activity_id, items } = req.body || {};
if (!activity_id || !Array.isArray(items)) {
return res.status(400).json({ error: '参数错误' });
}
const db = getDb();
try {
db.execCompat('BEGIN');
const upsert = db.prepareCompat(`
INSERT INTO hours (activity_id, user_id, hours, remark)
VALUES (?,?,?,?)
ON CONFLICT(activity_id, user_id) DO UPDATE SET hours=excluded.hours, remark=excluded.remark
`);
const delZero = db.prepareCompat('DELETE FROM hours WHERE activity_id = ? AND user_id = ? AND hours = 0 AND (remark IS NULL OR remark = \'\')');
for (const it of items) {
const h = Number(it.hours) || 0;
const r = (it.remark ?? '').toString();
if (h === 0 && !r) {
delZero.run(activity_id, it.user_id);
} else {
upsert.run(activity_id, it.user_id, h, r);
}
}
db.execCompat('COMMIT');
db.save();
res.json({ ok: true });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e.message });
}
});
router.get('/activity/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const act = db.prepareCompat('SELECT * FROM activities WHERE id = ?').get(id) as any;
if (!act) return res.status(404).json({ error: '活动不存在' });
const rows = db.prepareCompat(`
SELECT h.id, h.hours, h.remark, h.created_at,
u.id AS user_id, u.class, u.name, u.volunteer_no,
g.name AS group_name
FROM hours h
JOIN users u ON h.user_id = u.id
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE h.activity_id = ?
ORDER BY u.class, u.name
`).all(id);
const totalHours = rows.reduce((s: number, r: any) => s + (Number(r.hours) || 0), 0);
res.json({ activity: act, list: rows, total_hours: totalHours, total_people: rows.length });
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
import { Router, Request, Response } from 'express';
import { getDb } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', authMiddleware, (req: AuthRequest, res: Response) => {
const db = getDb();
const list = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, u.group_id,
g.name AS group_name,
COALESCE((SELECT SUM(h.hours) FROM hours h WHERE h.user_id = u.id), 0) AS total_hours
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY u.class ASC, u.name ASC
`).all();
res.json(list);
});
router.get('/search', (req: Request, res: Response) => {
const name = String(req.query.name || '').trim();
if (!name) return res.json([]);
const db = getDb();
const list = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.name LIKE ?
ORDER BY u.class ASC
`).all(`%${name}%`);
res.json(list);
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const user = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, u.group_id, g.name AS group_name,
COALESCE((SELECT SUM(h.hours) FROM hours h WHERE h.user_id = u.id), 0) AS total_hours
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.id = ?
`).get(id) as any;
if (!user) return res.status(404).json({ error: '部员不存在' });
const activities = db.prepareCompat(`
SELECT a.id, a.title, a.start_time, a.end_time, h.hours, h.remark
FROM hours h
JOIN activities a ON h.activity_id = a.id
WHERE h.user_id = ?
ORDER BY a.start_time DESC
`).all(id);
res.json({ ...user, activities });
});
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { class: cls, name, volunteer_no, group_id } = req.body || {};
if (!cls || !name || !String(cls).trim() || !String(name).trim()) {
return res.status(400).json({ error: '班级和姓名不能为空' });
}
const db = getDb();
try {
const info = db.prepareCompat('INSERT INTO users (class, name, volunteer_no, group_id) VALUES (?,?,?,?)')
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, group_id || null);
db.save();
res.json({ id: info.lastInsertRowid });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '该班级下已存在同名部员' });
}
res.status(500).json({ error: e.message });
}
});
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { class: cls, name, volunteer_no, group_id } = req.body || {};
if (!cls || !name || !String(cls).trim() || !String(name).trim()) {
return res.status(400).json({ error: '班级和姓名不能为空' });
}
const db = getDb();
try {
db.prepareCompat('UPDATE users SET class=?, name=?, volunteer_no=?, group_id=? WHERE id=?')
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, group_id || null, id);
db.save();
res.json({ ok: true });
} catch (e: any) {
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '该班级下已存在同名部员' });
}
res.status(500).json({ error: e.message });
}
});
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
db.prepareCompat('DELETE FROM users WHERE id = ?').run(id);
db.save();
res.json({ ok: true });
});
export default router;