import { useEffect, useMemo, useState } from 'react'; import Button from '../../components/Button'; import Input from '../../components/Input'; import DataTable from '../../components/DataTable'; import Modal from '../../components/Modal'; import Textarea from '../../components/Textarea'; import { groupsApi, usersApi, errMsg } from '../../api'; import { formatDateRange } from '../../utils/export'; import type { UserItem, UserDetail, WorkGroup } from '../../types'; import { IconUsers, IconPlus, IconEdit, IconTrash, IconEye } from '../../components/Icons'; type Form = { class: string; name: string; volunteer_no: string; group_ids: number[]; }; const emptyForm: Form = { class: '', name: '', volunteer_no: '', group_ids: [] }; type BatchParsedRow = { idx: number; class: string; name: string; volunteer_no: string; group_ids: number[]; group_names: string[]; raw: string; errors: string[]; }; function GroupCheckboxes({ groups, selected, onChange, }: { groups: WorkGroup[]; selected: number[]; onChange: (ids: number[]) => void; }) { const toggle = (id: number) => { if (selected.includes(id)) onChange(selected.filter((x) => x !== id)); else onChange([...selected, id]); }; if (groups.length === 0) { return
暂无工作组,请先在工作组管理中创建
; } return (
{groups.map((g) => { const active = selected.includes(g.id); return ( ); })}
); } function GroupBadges({ names }: { names: string[] }) { if (!names || names.length === 0) return —; return (
{names.map((n, i) => ( {n} ))}
); } export default function Users() { const [list, setList] = useState([]); const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(false); const [keyword, setKeyword] = useState(''); const [modalOpen, setModalOpen] = useState(false); const [form, setForm] = useState
(emptyForm); const [editId, setEditId] = useState(null); const [error, setError] = useState(''); const [detailOpen, setDetailOpen] = useState(false); const [detail, setDetail] = useState(null); const [batchOpen, setBatchOpen] = useState(false); const [batchText, setBatchText] = useState(''); const [batchErrors, setBatchErrors] = useState(''); const [selectedIds, setSelectedIds] = useState>(new Set()); const load = async () => { setLoading(true); try { const [u, g] = await Promise.all([usersApi.list(), groupsApi.list()]); const filled = u.map((x) => ({ ...x, group_ids: x.group_ids ?? [], group_names: x.group_names ?? [], })); filled.sort((a, b) => { const c = (a.class || '').localeCompare(b.class || '', 'zh-Hans-CN', { numeric: true }); if (c !== 0) return c; return (a.name || '').localeCompare(b.name || '', 'zh-Hans-CN'); }); setList(filled); setGroups(g); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const filtered = list.filter((u) => { if (!keyword.trim()) return true; const k = keyword.trim().toLowerCase(); const namesJoined = (u.group_names || []).join(' '); return [u.name, u.class, u.volunteer_no, namesJoined].some((s) => String(s || '').toLowerCase().includes(k) ); }); 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)); if (!result || result.ok === false) { alert('批量删除失败:' + ((result as any)?.error || '未知错误')); return; } alert(`批量删除完成:共提交 ${result.total ?? 0} 条,实际删除 ${result.deleted ?? 0} 条`); clearSelection(); load(); } catch (e) { alert('批量删除失败:' + errMsg(e)); } }; const openNew = () => { setForm(emptyForm); setEditId(null); setError(''); setModalOpen(true); }; const openEdit = (u: UserItem) => { setForm({ class: u.class, name: u.name, volunteer_no: u.volunteer_no || '', group_ids: [...(u.group_ids || [])], }); setEditId(u.id); setError(''); setModalOpen(true); }; const onDelete = (u: UserItem) => { if (!confirm(`确认删除部员「${u.class} ${u.name}」?其所有时长登记也会一并删除。`)) return; usersApi.remove(u.id).then(load).catch((e) => alert(errMsg(e))); }; const openDetail = async (u: UserItem) => { try { const d = await usersApi.detail(u.id); setDetail({ ...d, group_ids: d.group_ids ?? [], group_names: d.group_names ?? [] }); setDetailOpen(true); } catch (e) { alert(errMsg(e)); } }; const submit = async () => { if (!form.class.trim() || !form.name.trim()) { setError('班级与姓名不能为空'); return; } try { const payload = { class: form.class.trim(), name: form.name.trim(), volunteer_no: form.volunteer_no.trim() || null, group_ids: form.group_ids, }; if (editId) await usersApi.update(editId, payload); else await usersApi.create(payload); setModalOpen(false); load(); } catch (e) { setError(errMsg(e)); } }; const batchParsed: BatchParsedRow[] = useMemo(() => { if (!batchText.trim()) return []; const rows: BatchParsedRow[] = []; const lines = batchText.split(/\r?\n/); const groupMap = new Map(); const groupNameNormalized = new Map(); for (const g of groups) { const key = g.name.trim(); groupMap.set(key, g.id); groupNameNormalized.set(key, g.name); groupMap.set(g.name, g.id); groupNameNormalized.set(g.name, g.name); const noSpace = key.replace(/\s+/g, ''); if (noSpace !== key) { groupMap.set(noSpace, g.id); groupNameNormalized.set(noSpace, g.name); } } const splitGroupNames = (s: string): string[] => { const result: string[] = []; if (!s) return result; const segs = s.split(/[\/、||,,;;\s\u3000]+/); for (const seg of segs) { const n = seg.trim().replace(/\s+/g, ''); if (n) result.push(n); } 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; let parts: string[]; if (line.includes('\t')) { parts = line.split(/\t/).map((p) => p.trim()); } else { parts = line.split(/[,,;;]/).map((p) => p.trim()); } 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(groupsRaw); for (const n of rawNames) { if (!n) continue; const gid = groupMap.get(n); if (gid === undefined) { errors.push(`工作组「${n}」不存在`); } else { if (!groupIds.includes(gid)) { groupIds.push(gid); const display = groupNameNormalized.get(n) || n; if (!groupNames.includes(display)) groupNames.push(display); } } } rows.push({ idx: i, class: cls, name: name, volunteer_no: volunteer_no, group_ids: groupIds, group_names: groupNames, raw: line, errors, }); } return rows; }, [batchText, groups]); const canSubmitBatch = batchParsed.length > 0 && batchParsed.every((r) => r.errors.length === 0); const submitBatch = async () => { if (!canSubmitBatch) { setBatchErrors('请先修正上表中高亮的错误后再提交'); return; } try { const items = batchParsed.map((r) => ({ class: r.class, name: r.name, volunteer_no: r.volunteer_no || null, group_ids: r.group_ids, })); console.log('[DEBUG] batch payload items (first 3):', JSON.stringify(items.slice(0, 3), null, 2)); const result = await usersApi.batch(items); console.log('[DEBUG] batch response:', result); setBatchErrors(''); const totalActualWritten = result.results.filter(r => r.ok).reduce((sum: number, r: any) => sum + (r?.groups_written ?? 0), 0); const totalExpectedWritten = result.results.filter(r => r.ok).reduce((sum: number, r: any) => sum + (r?.groups_expected ?? 0), 0); const msg = `批量添加完成:共 ${result.total} 条,成功 ${result.success} 条,失败 ${result.failed} 条 工作组写入:实际 ${totalActualWritten} 条,期望 ${totalExpectedWritten} 条`; if (result.failed === 0) { const mis = result.results.filter((r: any) => r.ok && (r?.groups_written ?? 0) !== (r?.groups_expected ?? 0)); if (mis.length === 0) { alert(msg); } else { const lines = mis.slice(0, 20).map((r: any) => `第${r.idx + 1}行 ${r.item.name}:期望 ${r.groups_expected} 组,实际写入 ${r.groups_written} 组(后端解析 group_ids_out=${JSON.stringify(r.group_ids_out ?? [])})`).join('\n'); alert(`${msg}\n\n工作组未全部写入(前20):\n${lines}`); } setBatchOpen(false); setBatchText(''); load(); } else { const failLines = result.results .filter((r) => !r.ok) .map((r: any) => `第${r.idx + 1}行 [${r.item.class} ${r.item.name}]:${r.error}(后端接收 group_ids_count=${r.group_ids_count ?? 'N/A'},内容=${JSON.stringify(r.group_ids_seen ?? null)})`) .join('\n'); alert(`${msg}\n\n失败明细:\n${failLines}`); load(); } } catch (e) { setBatchErrors(errMsg(e)); } }; return (

用户管理

管理部员信息,支持多工作组归属,查看个人累计义工时长与参与活动明细

setKeyword(e.target.value)} className="md:max-w-md" />
{selectedCount > 0 && ( )}
columns={[ { key: '__sel', title: ( ), width: '120px', render: (r) => ( toggleSelectOne(r.id)} /> ), }, { key: 'class', title: '班级', width: '120px', render: (r) => {r.class} }, { key: 'name', title: '姓名', render: (r) => {r.name} }, { key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' }, { key: 'group_name', title: '所属工作组', width: '260px', render: (r) => , }, { key: 'total_hours', title: '累计时长(小时)', render: (r) => {Number(r.total_hours || 0).toFixed(2)}, }, { key: 'op', title: '操作', width: '240px', render: (r) => (
), }, ]} data={filtered} rowKey={(r) => r.id} /> setModalOpen(false)} footer={ <> } >
setForm({ ...form, class: e.target.value })} placeholder="如:高三(1)班" />
setForm({ ...form, name: e.target.value })} />
setForm({ ...form, volunteer_no: e.target.value })} />
setForm({ ...form, group_ids: ids })} />
{error &&
{error}
}
setBatchOpen(false)} width="max-w-5xl" footer={ <> } >
格式说明(每行一条记录):
格式:班级,姓名,义工号,工作组1/工作组2
· 分隔符支持:逗号(中英)、Tab、分号、竖线
· 工作组分隔符支持:斜杠 / 、顿号 、竖线 | 空格
· 义工号可为空;工作组请填写已创建的工作组名称,可多选
· 支持直接从 Excel 表格复制整列粘贴