- 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:
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user