fix: 修复批量导入义工号/工作组丢失 + 新增批量删除
Deploy to Server / deploy (push) Failing after 43s

- 批量解析:移除义工号强制纯数字校验(原 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:
Trae Assistant
2026-10-05 08:27:34 +08:00
parent 13dbff885d
commit 48ac08d19d
3 changed files with 151 additions and 24 deletions
+6
View File
@@ -65,6 +65,12 @@ export const usersApi = {
failed: number;
results: Array<{ idx: number; ok: boolean; id?: number; error?: string; item: any }>;
}>('/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 = {
+107 -21
View File
@@ -103,6 +103,8 @@ export default function Users() {
const [batchText, setBatchText] = useState('');
const [batchErrors, setBatchErrors] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const load = async () => {
setLoading(true);
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 = () => {
setForm(emptyForm);
setEditId(null);
@@ -218,6 +255,11 @@ export default function Users() {
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++) {
const line = lines[i];
if (!line.trim()) continue;
@@ -227,28 +269,42 @@ export default function Users() {
} else {
parts = line.split(/[,,;;]/).map((p) => p.trim());
}
const [cls = '', name = '', volunteer_no = '', ...rest] = parts;
const groupsStr = rest.join(',');
const nonEmptyParts = parts.filter((p) => p.length > 0);
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[] = [];
if (!cls) errors.push('缺少班级');
if (!name) errors.push('缺少姓名');
const groupIds: number[] = [];
const groupNames: string[] = [];
const rawNames = splitGroupNames([volunteer_no, groupsStr].filter(Boolean).join('/'));
const volunteerLooksLikeNo = /^\d+$/.test(volunteer_no.trim());
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) {
const rawNames = splitGroupNames(groupsRaw);
for (const n of rawNames) {
if (!n) continue;
const gid = groupMap.get(n);
if (gid === undefined) {
@@ -261,12 +317,11 @@ export default function Users() {
}
}
}
const vol = volunteerLooksLikeNo ? volunteer_no : '';
rows.push({
idx: i,
class: cls || '',
name: name || '',
volunteer_no: vol,
class: cls,
name: name,
volunteer_no: volunteer_no,
group_ids: groupIds,
group_names: groupNames,
raw: line,
@@ -331,6 +386,13 @@ export default function Users() {
className="md:max-w-md"
/>
<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)}>
<span className="inline-flex items-center gap-2">
<IconUsers size={18} />批量添加部员
@@ -346,6 +408,30 @@ export default function Users() {
<DataTable<UserItem>
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: 'name', title: '姓名', render: (r) => <span className="font-semibold">{r.name}</span> },
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },