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
+8
View File
@@ -82,6 +82,14 @@ export const hoursApi = {
batch: (activity_id: number, items: Array<{ user_id: number; hours: number; remark: string }>) => batch: (activity_id: number, items: Array<{ user_id: number; hours: number; remark: string }>) =>
api.post('/hours/batch', { activity_id, items }).then(r => r.data), api.post('/hours/batch', { activity_id, items }).then(r => r.data),
byActivity: (id: number) => api.get<HoursActivityResult>(`/hours/activity/${id}`).then(r => r.data), byActivity: (id: number) => api.get<HoursActivityResult>(`/hours/activity/${id}`).then(r => r.data),
directAdd: (items: Array<{ user_id: number; hours: number; reason: string }>) =>
api.post<{
ok: boolean;
added: number;
total_hours: number;
results: Array<{ user_id: number; class: string; name: string; volunteer_no: string | null; hours: number; reason: string }>;
direct_add_activity_id: number;
}>('/hours/direct-add', { items }).then(r => r.data),
}; };
export const exportApi = { export const exportApi = {
+159 -2
View File
@@ -4,7 +4,7 @@ import Input from '../../components/Input';
import DataTable from '../../components/DataTable'; import DataTable from '../../components/DataTable';
import Modal from '../../components/Modal'; import Modal from '../../components/Modal';
import Textarea from '../../components/Textarea'; import Textarea from '../../components/Textarea';
import { groupsApi, usersApi, errMsg } from '../../api'; import { groupsApi, usersApi, hoursApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export'; import { formatDateRange } from '../../utils/export';
import type { UserItem, UserDetail, WorkGroup } from '../../types'; import type { UserItem, UserDetail, WorkGroup } from '../../types';
import { IconUsers, IconPlus, IconEdit, IconTrash, IconEye } from '../../components/Icons'; import { IconUsers, IconPlus, IconEdit, IconTrash, IconEye } from '../../components/Icons';
@@ -105,6 +105,13 @@ export default function Users() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [directOpen, setDirectOpen] = useState(false);
const [directUserIds, setDirectUserIds] = useState<number[]>([]);
const [directHours, setDirectHours] = useState('');
const [directReason, setDirectReason] = useState('');
const [directError, setDirectError] = useState('');
const [directSubmitting, setDirectSubmitting] = useState(false);
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -208,6 +215,62 @@ export default function Users() {
} }
}; };
const openBatchDirectAdd = () => {
if (selectedCount === 0) return;
setDirectUserIds(Array.from(selectedIds));
setDirectHours('');
setDirectReason('');
setDirectError('');
setDirectOpen(true);
};
const openSingleDirectAdd = (u: { id: number }) => {
setDirectUserIds([u.id]);
setDirectHours('');
setDirectReason('');
setDirectError('');
setDirectOpen(true);
};
const refreshDetailAfterDirect = async () => {
if (!detailOpen || !detail) return;
try {
const d = await usersApi.detail(detail.id);
setDetail({ ...d, group_ids: d.group_ids ?? [], group_names: d.group_names ?? [] });
} catch {}
};
const submitDirectAdd = async () => {
setDirectError('');
const h = Number(directHours);
const reason = directReason.trim();
if (!Number.isFinite(h) || h <= 0) {
setDirectError('时长必须是大于 0 的数字');
return;
}
if (!reason) {
setDirectError('必须填写原因(将作为备注保存)');
return;
}
if (directUserIds.length === 0) {
setDirectError('请先选择部员');
return;
}
setDirectSubmitting(true);
try {
const items = directUserIds.map((id) => ({ user_id: id, hours: h, reason }));
const result = await hoursApi.directAdd(items);
const names = result.results.slice(0, 5).map((r) => `${r.class}${r.name}`).join('、');
const more = result.results.length > 5 ? ` 等 ${result.results.length} 人` : '';
alert(`补录完成:已为 ${names}${more} 累计加 ${Number(result.total_hours).toFixed(1)} 小时`);
setDirectOpen(false);
clearSelection();
await load();
await refreshDetailAfterDirect();
} catch (e) {
setDirectError(errMsg(e, '补录义工时失败'));
} finally {
setDirectSubmitting(false);
}
};
const submit = async () => { const submit = async () => {
if (!form.class.trim() || !form.name.trim()) { if (!form.class.trim() || !form.name.trim()) {
setError('班级与姓名不能为空'); setError('班级与姓名不能为空');
@@ -402,11 +465,18 @@ export default function Users() {
/> />
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{selectedCount > 0 && ( {selectedCount > 0 && (
<>
<Button onClick={openBatchDirectAdd}>
<span className="inline-flex items-center gap-2">
<IconPlus size={18} />批量加义工时({selectedCount})
</span>
</Button>
<Button variant="danger" onClick={batchDelete}> <Button variant="danger" onClick={batchDelete}>
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<IconTrash size={18} />删除选中({selectedCount}) <IconTrash size={18} />删除选中({selectedCount})
</span> </span>
</Button> </Button>
</>
)} )}
<Button variant="secondary" onClick={() => setBatchOpen(true)}> <Button variant="secondary" onClick={() => setBatchOpen(true)}>
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
@@ -657,12 +727,19 @@ export default function Users() {
<div className="muted mb-1">义工号</div> <div className="muted mb-1">义工号</div>
<div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div> <div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div>
</div> </div>
<div className="card p-3 bg-primary-50/60"> <div className="card p-3 bg-primary-50/60 flex flex-col justify-between gap-2">
<div>
<div className="muted mb-1">累计时长</div> <div className="muted mb-1">累计时长</div>
<div className="font-semibold text-lg text-primary-600"> <div className="font-semibold text-lg text-primary-600">
{Number(detail.total_hours || 0).toFixed(1)}h {Number(detail.total_hours || 0).toFixed(1)}h
</div> </div>
</div> </div>
<Button size="sm" onClick={() => openSingleDirectAdd(detail)} className="justify-center">
<span className="inline-flex items-center gap-1">
<IconPlus size={14} />加义工时
</span>
</Button>
</div>
</div> </div>
<div className="card p-3"> <div className="card p-3">
<div className="muted mb-2">所属工作组</div> <div className="muted mb-2">所属工作组</div>
@@ -692,6 +769,86 @@ export default function Users() {
</div> </div>
)} )}
</Modal> </Modal>
<Modal
open={directOpen}
title="直接加义工时"
onClose={() => !directSubmitting && setDirectOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setDirectOpen(false)} disabled={directSubmitting}>取消</Button>
<Button onClick={submitDirectAdd} disabled={directSubmitting}>
{directSubmitting ? '提交中...' : `确认加义工时(${directUserIds.length} 人)`}
</Button>
</>
}
>
<div className="space-y-4 max-w-xl">
<div className="card p-3 bg-primary-50/50 border border-primary-100 rounded-md">
<div className="text-sm text-gray-700 leading-relaxed">
<div className="font-semibold text-gray-800 mb-1">说明</div>
<div className="muted text-xs mb-2 leading-relaxed">
· 录入后会作为独立的时长记录挂在虚拟活动「【补录】直接加义工时长」名下<br />
· 每次直接加时都会单独保存一条完整记录(含原因),可在明细里逐条追溯<br />
· 「原因」将作为该条时长的备注显示
</div>
<div className="mt-2 pt-2 border-t border-primary-100">
<div className="text-sm font-medium text-gray-700 mb-1">
已选择 <span className="text-primary-600 font-bold">{directUserIds.length}</span> 名部员:
</div>
<div className="text-xs text-gray-600 max-h-24 overflow-auto">
{(() => {
const namesMap = new Map<number, string>();
for (const u of list) namesMap.set(u.id, `${u.class}${u.name}`);
return directUserIds.map((id, i) => (
<span
key={id}
className="inline-block mr-2 mb-1 px-2 py-0.5 bg-white rounded border border-gray-200"
>
{i + 1}. {namesMap.get(id) || `部员ID#${id}`}
</span>
));
})()}
</div>
</div>
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5">
时长(小时)*
<span className="ml-2 text-xs text-gray-500 font-normal">支持 0.5h / 1h / 1.5h 等</span>
</label>
<Input
type="number"
min={0.1}
step={0.1}
value={directHours}
onChange={(e) => setDirectHours(e.target.value)}
placeholder="请输入要加的义工小时数(必填,> 0)"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5">
原因 / 备注 *
<span className="ml-2 text-xs text-red-600 font-normal">必填,将作为该条时长记录的备注保留</span>
</label>
<Textarea
rows={3}
value={directReason}
onChange={(e) => setDirectReason(e.target.value)}
placeholder="例如:协助完成宣传部招新面试;临时值班;特殊贡献奖励等"
/>
<div className="mt-1 text-xs muted">
已输入 {directReason.length} 字{directReason.length === 0 ? '(请填写原因后再提交)' : ''}
</div>
</div>
{directError && (
<div className="text-sm text-red-600 rounded-md border border-red-200 bg-red-50 px-3 py-2 leading-relaxed">
{directError}
</div>
)}
</div>
</Modal>
</div> </div>
); );
} }
+41
View File
@@ -136,6 +136,47 @@ export async function initDb() {
} }
} catch (e) { console.error('User_groups migration error', e); } } 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(); const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
if (!adminCount || adminCount.c === 0) { if (!adminCount || adminCount.c === 0) {
const hash = bcrypt.hashSync('admin123', 10); const hash = bcrypt.hashSync('admin123', 10);
+1 -2
View File
@@ -46,8 +46,7 @@ CREATE TABLE IF NOT EXISTS hours (
remark TEXT, remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')), created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(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 ( 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(); const db = getDb();
try { try {
db.execCompat('BEGIN'); 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) INSERT INTO hours (activity_id, user_id, hours, remark)
VALUES (?,?,?,?) 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) { for (const it of items) {
const h = Number(it.hours) || 0; const h = Number(it.hours) || 0;
const r = (it.remark ?? '').toString(); const r = (it.remark ?? '').toString();
if (h === 0 && !r) { if (h === 0 && !r) {
delZero.run(activity_id, it.user_id); delOld.run(activity_id, it.user_id);
} else { } 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'); 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) => { router.get('/activity/:id', (req: Request, res: Response) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const db = getDb(); const db = getDb();