- 批量解析:移除义工号强制纯数字校验(原 volunteerLooksLikeNo 逻辑错误导致 VOL-003、2024-A 等全部置空);改为列数驱动:3列时智能探测是否工作组命中,4+列保留义工号原值、剩余段统一合并为工作组解析 - 后端 batch:user_groups 插入语句改为事务内按用户编译;义工号判断改为 null/undefined 严格检查,避免真值判断丢弃空字符串或 0 开头的值 - 新增 DELETE /users/batch:事务性批量删除部员(含 user_groups/hours 级联),返回实际删除 count - 前端列表新增复选框列(表头 indeterminate 半选态),选中 1+ 条后工具栏显示「删除选中(N)」红色按钮,确认弹窗二次校验
This commit is contained in:
@@ -65,6 +65,12 @@ export const usersApi = {
|
|||||||
failed: number;
|
failed: number;
|
||||||
results: Array<{ idx: number; ok: boolean; id?: number; error?: string; item: any }>;
|
results: Array<{ idx: number; ok: boolean; id?: number; error?: string; item: any }>;
|
||||||
}>('/users/batch', { items }).then(r => r.data),
|
}>('/users/batch', { items }).then(r => r.data),
|
||||||
|
batchRemove: (ids: number[]) =>
|
||||||
|
api.request<{ ok: boolean; deleted: number; total: number }>({
|
||||||
|
url: '/users/batch',
|
||||||
|
method: 'DELETE',
|
||||||
|
data: { ids },
|
||||||
|
}).then(r => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const activitiesApi = {
|
export const activitiesApi = {
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export default function Users() {
|
|||||||
const [batchText, setBatchText] = useState('');
|
const [batchText, setBatchText] = useState('');
|
||||||
const [batchErrors, setBatchErrors] = useState<string>('');
|
const [batchErrors, setBatchErrors] = useState<string>('');
|
||||||
|
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -136,6 +138,41 @@ export default function Users() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filteredIds = filtered.map((u) => u.id);
|
||||||
|
const allSelected = filteredIds.length > 0 && filteredIds.every((id) => selectedIds.has(id));
|
||||||
|
const someSelected = filteredIds.some((id) => selectedIds.has(id));
|
||||||
|
const selectedCount = selectedIds.size;
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (allSelected) {
|
||||||
|
for (const id of filteredIds) next.delete(id);
|
||||||
|
} else {
|
||||||
|
for (const id of filteredIds) next.add(id);
|
||||||
|
}
|
||||||
|
setSelectedIds(next);
|
||||||
|
};
|
||||||
|
const toggleSelectOne = (id: number) => {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
setSelectedIds(next);
|
||||||
|
};
|
||||||
|
const clearSelection = () => setSelectedIds(new Set());
|
||||||
|
|
||||||
|
const batchDelete = async () => {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
if (!confirm(`确认删除选中的 ${selectedCount} 名部员?相关的时长登记也会一并删除,此操作不可撤销。`)) return;
|
||||||
|
try {
|
||||||
|
const result = await usersApi.batchRemove(Array.from(selectedIds));
|
||||||
|
alert(`批量删除完成:共提交 ${result.total} 条,实际删除 ${result.deleted} 条`);
|
||||||
|
clearSelection();
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
alert(errMsg(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
@@ -218,6 +255,11 @@ export default function Users() {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allGroupMatch = (names: string[]): boolean => {
|
||||||
|
if (names.length === 0) return false;
|
||||||
|
return names.every((n) => groupMap.has(n));
|
||||||
|
};
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
@@ -227,28 +269,42 @@ export default function Users() {
|
|||||||
} else {
|
} else {
|
||||||
parts = line.split(/[,,;;]/).map((p) => p.trim());
|
parts = line.split(/[,,;;]/).map((p) => p.trim());
|
||||||
}
|
}
|
||||||
const [cls = '', name = '', volunteer_no = '', ...rest] = parts;
|
const nonEmptyParts = parts.filter((p) => p.length > 0);
|
||||||
const groupsStr = rest.join(',');
|
|
||||||
|
let cls = '';
|
||||||
|
let name = '';
|
||||||
|
let volunteer_no = '';
|
||||||
|
let groupsRaw = '';
|
||||||
|
|
||||||
|
if (nonEmptyParts.length >= 2) {
|
||||||
|
cls = parts[0] || '';
|
||||||
|
name = parts[1] || '';
|
||||||
|
if (nonEmptyParts.length === 2) {
|
||||||
|
volunteer_no = '';
|
||||||
|
groupsRaw = '';
|
||||||
|
} else if (nonEmptyParts.length === 3) {
|
||||||
|
const third = parts[2] || '';
|
||||||
|
const probe = splitGroupNames(third);
|
||||||
|
if (allGroupMatch(probe)) {
|
||||||
|
volunteer_no = '';
|
||||||
|
groupsRaw = third;
|
||||||
|
} else {
|
||||||
|
volunteer_no = third;
|
||||||
|
groupsRaw = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
volunteer_no = parts[2] || '';
|
||||||
|
groupsRaw = parts.slice(3).join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!cls) errors.push('缺少班级');
|
if (!cls) errors.push('缺少班级');
|
||||||
if (!name) errors.push('缺少姓名');
|
if (!name) errors.push('缺少姓名');
|
||||||
const groupIds: number[] = [];
|
const groupIds: number[] = [];
|
||||||
const groupNames: string[] = [];
|
const groupNames: string[] = [];
|
||||||
const rawNames = splitGroupNames([volunteer_no, groupsStr].filter(Boolean).join('/'));
|
const rawNames = splitGroupNames(groupsRaw);
|
||||||
const volunteerLooksLikeNo = /^\d+$/.test(volunteer_no.trim());
|
for (const n of rawNames) {
|
||||||
const realRawNames: string[] = [];
|
|
||||||
if (volunteer_no.trim() && !volunteerLooksLikeNo) {
|
|
||||||
const probe = splitGroupNames(volunteer_no);
|
|
||||||
const allMatch = probe.length > 0 && probe.every((n) => groupMap.has(n));
|
|
||||||
if (allMatch) {
|
|
||||||
realRawNames.push(...probe);
|
|
||||||
} else {
|
|
||||||
realRawNames.push(...splitGroupNames(groupsStr));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
realRawNames.push(...splitGroupNames(groupsStr));
|
|
||||||
}
|
|
||||||
for (const n of realRawNames) {
|
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
const gid = groupMap.get(n);
|
const gid = groupMap.get(n);
|
||||||
if (gid === undefined) {
|
if (gid === undefined) {
|
||||||
@@ -261,12 +317,11 @@ export default function Users() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const vol = volunteerLooksLikeNo ? volunteer_no : '';
|
|
||||||
rows.push({
|
rows.push({
|
||||||
idx: i,
|
idx: i,
|
||||||
class: cls || '',
|
class: cls,
|
||||||
name: name || '',
|
name: name,
|
||||||
volunteer_no: vol,
|
volunteer_no: volunteer_no,
|
||||||
group_ids: groupIds,
|
group_ids: groupIds,
|
||||||
group_names: groupNames,
|
group_names: groupNames,
|
||||||
raw: line,
|
raw: line,
|
||||||
@@ -331,6 +386,13 @@ export default function Users() {
|
|||||||
className="md:max-w-md"
|
className="md:max-w-md"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<Button variant="danger" onClick={batchDelete}>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<IconTrash size={18} />删除选中({selectedCount})
|
||||||
|
</span>
|
||||||
|
</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">
|
||||||
<IconUsers size={18} />批量添加部员
|
<IconUsers size={18} />批量添加部员
|
||||||
@@ -346,6 +408,30 @@ export default function Users() {
|
|||||||
|
|
||||||
<DataTable<UserItem>
|
<DataTable<UserItem>
|
||||||
columns={[
|
columns={[
|
||||||
|
{
|
||||||
|
key: '__sel',
|
||||||
|
title: (
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-[#275354] w-4 h-4"
|
||||||
|
checked={allSelected}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = !allSelected && someSelected; }}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600">{selectedCount > 0 ? `已选 ${selectedCount}` : '全选'}</span>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
width: '120px',
|
||||||
|
render: (r) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-[#275354] w-4 h-4"
|
||||||
|
checked={selectedIds.has(r.id)}
|
||||||
|
onChange={() => toggleSelectOne(r.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ key: 'class', title: '班级', width: '120px', render: (r) => <span className="whitespace-nowrap">{r.class}</span> },
|
{ key: 'class', title: '班级', width: '120px', render: (r) => <span className="whitespace-nowrap">{r.class}</span> },
|
||||||
{ key: 'name', title: '姓名', render: (r) => <span className="font-semibold">{r.name}</span> },
|
{ key: 'name', title: '姓名', render: (r) => <span className="font-semibold">{r.name}</span> },
|
||||||
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
||||||
|
|||||||
@@ -187,13 +187,14 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
db.execCompat('BEGIN');
|
db.execCompat('BEGIN');
|
||||||
const insertUser = db.prepareCompat('INSERT INTO users (class, name, volunteer_no, group_id) VALUES (?,?,?,?)');
|
const insertUser = db.prepareCompat('INSERT INTO users (class, name, volunteer_no, group_id) VALUES (?,?,?,?)');
|
||||||
const insertUG = db.prepareCompat('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)');
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const raw = items[i] || {};
|
const raw = items[i] || {};
|
||||||
const cls = String(raw.class || '').trim();
|
const cls = String(raw.class || '').trim();
|
||||||
const name = String(raw.name || '').trim();
|
const name = String(raw.name || '').trim();
|
||||||
const volunteer_no = raw.volunteer_no ? String(raw.volunteer_no).trim() || null : null;
|
const volunteer_no = (raw.volunteer_no === undefined || raw.volunteer_no === null)
|
||||||
|
? null
|
||||||
|
: (String(raw.volunteer_no).trim() || null);
|
||||||
if (!cls || !name) {
|
if (!cls || !name) {
|
||||||
failed++;
|
failed++;
|
||||||
results.push({ idx: i, ok: false, error: '班级和姓名不能为空', item: raw });
|
results.push({ idx: i, ok: false, error: '班级和姓名不能为空', item: raw });
|
||||||
@@ -213,7 +214,10 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const info = insertUser.run(cls, name, volunteer_no, firstGroup);
|
const info = insertUser.run(cls, name, volunteer_no, firstGroup);
|
||||||
const userId = Number(info.lastInsertRowid);
|
const userId = Number(info.lastInsertRowid);
|
||||||
for (const gid of groups) insertUG.run(userId, gid);
|
if (groups.length > 0) {
|
||||||
|
const insertUG = db.prepareCompat('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)');
|
||||||
|
for (const gid of groups) insertUG.run(userId, gid);
|
||||||
|
}
|
||||||
success++;
|
success++;
|
||||||
results.push({ idx: i, ok: true, id: userId, item: raw });
|
results.push({ idx: i, ok: true, id: userId, item: raw });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -232,4 +236,35 @@ router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.delete('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||||
|
const { ids } = req.body || {};
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return res.status(400).json({ error: '请选择要删除的部员' });
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const list: number[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const n = Number(id);
|
||||||
|
if (!isNaN(n) && n > 0 && !list.includes(n)) list.push(n);
|
||||||
|
}
|
||||||
|
if (list.length === 0) {
|
||||||
|
return res.status(400).json({ error: '请选择有效的部员' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.execCompat('BEGIN');
|
||||||
|
const del = db.prepareCompat('DELETE FROM users WHERE id = ?');
|
||||||
|
let count = 0;
|
||||||
|
for (const id of list) {
|
||||||
|
const r = del.run(id);
|
||||||
|
count += (r?.changes ?? 0);
|
||||||
|
}
|
||||||
|
db.execCompat('COMMIT');
|
||||||
|
db.save();
|
||||||
|
res.json({ ok: true, deleted: count, total: list.length });
|
||||||
|
} catch (e: any) {
|
||||||
|
try { db.execCompat('ROLLBACK'); } catch {}
|
||||||
|
res.status(500).json({ error: e?.message || '批量删除失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user