feat: 管理台直接对部员加义工时(必填原因,独立多条记录)
Deploy to Server / deploy (push) Successful in 1m41s

- schema.sql + db/index.ts 迁移:hours 表移除 UNIQUE(activity_id,user_id)
  约束,改由 id 自增主键唯一定位;启动时检测到旧结构会自动
  RENAME+重建表+INSERT 搬迁历史数据;外部 FK pragma 仍生效
- hours.ts batch 接口:为兼容去掉 UNIQUE 后的行为等价,改成
  『先 DELETE 旧行 + 再 INSERT 新行』,同人同活动仍为一行覆盖语义
- 新增 POST /hours/direct-add:
  · items = [{user_id, hours, reason}],每条记录前置校验(uid 存在、
    hours>0、reason 非空),错误以分号拼接返回
  · 首次调用自动创建活动「【补录】直接加义工时长」(start/end
    2024-01-01 ~ 2099-12-31)作为虚拟承载活动,后续复用
  · 全事务 INSERT,每次加时独立写入多行(不合并)
  · 响应:{ ok, added, total_hours, results:[{class,name,...}], direct_add_activity_id }
- api/index.ts hoursApi.directAdd:新增带完整返回类型签名的 TS 封装
- Users.tsx 双入口:
  · 工具栏:选中部员后与『批量删除』并列,出现『批量加义工时(N)』
    主按钮 → 打开共用 Modal 一次加多人
  · 部员详情弹窗:「累计时长」卡片独立加『加义工时』小按钮,
    点后复用同一表单只为当前人录 1 条
- 共用 Direct Add Modal:
  · 顶部说明卡(每条原因独立保存;挂在【补录】活动;勾选人名 chip 列表)
  · 时长输入:number,min=0.1 step=0.1,前端校验 >0
  · 原因输入:textarea 必填,标红字星号,字数计数,提交前 trim 非空校验
  · 提交 loading 锁按钮;成功 toast 显示前 5 人名字 + 总人数 + 总小时;
    然后 load() 刷新列表累计时长列,若详情弹窗开着自动重新拉详情
This commit is contained in:
Trae Assistant
2026-10-05 13:44:44 +08:00
parent dd2fe86998
commit 51b27bdfd4
5 changed files with 293 additions and 17 deletions
+41
View File
@@ -136,6 +136,47 @@ export async function initDb() {
}
} catch (e) { console.error('User_groups migration error', e); }
try {
const hoursExists = d.prepareCompat(`SELECT name FROM sqlite_master WHERE type='table' AND name='hours'`).get();
if (hoursExists) {
const uniqueIdx = d.prepareCompat(`
SELECT name, sql FROM sqlite_master
WHERE type='index' AND tbl_name='hours' AND (unique=1 OR sql LIKE '%UNIQUE%')
`).all();
const tblSql = (d.prepareCompat(`SELECT sql FROM sqlite_master WHERE type='table' AND name='hours'`).get() as any)?.sql || '';
const hasOldUnique = uniqueIdx.length > 0 || tblSql.includes('UNIQUE(activity_id, user_id)');
if (hasOldUnique) {
console.log('[DB] Migrating hours: drop UNIQUE(activity_id,user_id) constraint, allow multiple rows per activity+user');
d.execCompat('BEGIN');
d.execCompat(`CREATE TABLE IF NOT EXISTS hours_new (
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','+8 hours')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
d.execCompat(`
INSERT INTO hours_new (id, activity_id, user_id, hours, remark, created_at)
SELECT id, activity_id, user_id, hours, remark, created_at FROM hours
`);
for (const idx of uniqueIdx) {
try { d.execCompat(`DROP INDEX IF EXISTS "${idx.name}"`); } catch {}
}
d.execCompat('DROP TABLE IF EXISTS hours');
d.execCompat('ALTER TABLE hours_new RENAME TO hours');
d.execCompat('COMMIT');
d.save();
console.log('[DB] hours UNIQUE constraint migration done');
}
}
} catch (e: any) {
console.error('Hours migration error', e);
try { d.execCompat('ROLLBACK'); } catch {}
}
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
if (!adminCount || adminCount.c === 0) {
const hash = bcrypt.hashSync('admin123', 10);
+1 -2
View File
@@ -46,8 +46,7 @@ CREATE TABLE IF NOT EXISTS hours (
remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
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)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS admins (
+76 -5
View File
@@ -12,19 +12,19 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
const db = getDb();
try {
db.execCompat('BEGIN');
const upsert = db.prepareCompat(`
const delOld = db.prepareCompat('DELETE FROM hours WHERE activity_id = ? AND user_id = ?');
const insertNew = 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);
delOld.run(activity_id, it.user_id);
} else {
upsert.run(activity_id, it.user_id, h, r);
delOld.run(activity_id, it.user_id);
insertNew.run(activity_id, it.user_id, h, r);
}
}
db.execCompat('COMMIT');
@@ -36,6 +36,77 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
}
});
const DIRECT_ADD_TITLE = '【补录】直接加义工时长';
router.post('/direct-add', authMiddleware, (req: AuthRequest, res: Response) => {
const { items } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: '请至少选择一名部员' });
}
const db = getDb();
try {
const errors: string[] = [];
const clean: Array<{ uid: number; user: any; hours: number; reason: string }> = [];
for (const it of items) {
const uid = Number(it?.user_id);
const h = Number(it?.hours);
const reason = String(it?.reason ?? '').trim();
if (!uid) { errors.push(`无效部员:${JSON.stringify(it)}`); continue; }
const user = db.prepareCompat('SELECT id, class, name, volunteer_no FROM users WHERE id = ?').get(uid);
if (!user) { errors.push(`部员不存在:${uid}`); continue; }
if (!Number.isFinite(h) || h <= 0) {
errors.push(`${user.class} ${user.name} 的时长必须大于 0`);
continue;
}
if (!reason) {
errors.push(`${user.class} ${user.name} 必须填写原因`);
continue;
}
clean.push({ uid, user, hours: h, reason });
}
if (errors.length > 0) {
return res.status(400).json({ error: errors.join(';') });
}
db.execCompat('BEGIN');
let act = db.prepareCompat('SELECT * FROM activities WHERE title = ?').get(DIRECT_ADD_TITLE) as any;
if (!act) {
db.prepareCompat(`
INSERT INTO activities (title, content, start_time, end_time)
VALUES (?, ?, ?, ?)
`).run(DIRECT_ADD_TITLE, '管理台直接补录义工时长,不绑定真实活动', '2024-01-01 00:00', '2099-12-31 23:59');
act = db.prepareCompat('SELECT * FROM activities WHERE title = ?').get(DIRECT_ADD_TITLE) as any;
}
const insert = db.prepareCompat(`
INSERT INTO hours (activity_id, user_id, hours, remark)
VALUES (?,?,?,?)
`);
const results: any[] = [];
for (const c of clean) {
insert.run(act.id, c.uid, c.hours, c.reason);
results.push({
user_id: c.uid,
class: c.user.class,
name: c.user.name,
volunteer_no: c.user.volunteer_no || null,
hours: c.hours,
reason: c.reason,
});
}
db.execCompat('COMMIT');
db.save();
res.json({
ok: true,
added: results.length,
total_hours: results.reduce((s, r) => s + r.hours, 0),
results,
direct_add_activity_id: act.id,
});
} 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();