feat: 初始化义工时长管理系统
- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG) - 用户端:姓名查询个人义工时长与活动记录 - 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite) - 支持 Docker 部署,附带 Gitea Actions 自动部署工作流 - 响应式界面,兼容移动端与 PC 端 - 默认管理员账号:admin / admin123
This commit is contained in:
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