Files
VolunteerManage-for-byw/client/src/pages/admin/Users.tsx
T
Trae Assistant 49985a96ab
Deploy to Server / deploy (push) Successful in 1m37s
fix: 批量添加工工作组调试回显 + 完整验证写入
- 后端 /users/batch 返回每条成功记录的 groups_written/groups_expected/group_ids_out;失败记录返回 group_ids_seen/count
- 前端 submitBatch:console.log 打印发送 payload 和响应;弹窗汇总工作组实际/期望写入数;任何异常的行都显示后端实际解析出的 group_ids_out,失败行同时显示后端收到的 group_ids_seen
- 本地模拟后端 sql.js 写入 user_groups 100% 正确(场景A/B测试均 changes=1,聚合查询正确),此提交部署后通过控制台和弹窗即可精确定位问题在哪一跳(前端发送 vs 后端解析 vs 数据库写入)
- npm run build 通过
2026-10-05 08:46:25 +08:00

698 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <div className="muted text-sm">暂无工作组,请先在工作组管理中创建</div>;
}
return (
<div className="flex flex-wrap gap-2">
{groups.map((g) => {
const active = selected.includes(g.id);
return (
<label
key={g.id}
className={`cursor-pointer select-none inline-flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm transition-all ${
active
? 'bg-[#275354]/10 border-[#275354] text-[#275354] font-semibold'
: 'bg-white border-gray-300 text-gray-700 hover:border-gray-400'
}`}
>
<input
type="checkbox"
className="accent-[#275354] w-4 h-4"
checked={active}
onChange={() => toggle(g.id)}
/>
{g.name}
</label>
);
})}
</div>
);
}
function GroupBadges({ names }: { names: string[] }) {
if (!names || names.length === 0) return <span className="muted">—</span>;
return (
<div className="flex flex-wrap gap-1">
{names.map((n, i) => (
<span
key={i}
className="inline-block text-xs px-2 py-0.5 rounded-md bg-[#275354]/10 text-[#275354] border border-[#275354]/20 whitespace-nowrap"
>
{n}
</span>
))}
</div>
);
}
export default function Users() {
const [list, setList] = useState<UserItem[]>([]);
const [groups, setGroups] = useState<WorkGroup[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<Form>(emptyForm);
const [editId, setEditId] = useState<number | null>(null);
const [error, setError] = useState('');
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<UserDetail | null>(null);
const [batchOpen, setBatchOpen] = useState(false);
const [batchText, setBatchText] = useState('');
const [batchErrors, setBatchErrors] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<number>>(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<string, number>();
const groupNameNormalized = new Map<string, string>();
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 (
<div>
<div className="mb-5 flex items-start gap-2">
<div className="shrink-0 p-2 bg-primary-50 text-primary-600 rounded-md mt-0.5">
<IconUsers size={26} />
</div>
<div>
<h1 className="section-title">用户管理</h1>
<p className="muted mt-1">管理部员信息,支持多工作组归属,查看个人累计义工时长与参与活动明细</p>
</div>
</div>
<div className="card p-4 mb-4 flex flex-col md:flex-row gap-3 md:items-center md:justify-between">
<Input
placeholder="搜索 姓名/班级/义工号/工作组..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
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} />批量添加部员
</span>
</Button>
<Button onClick={openNew}>
<span className="inline-flex items-center gap-2">
<IconPlus size={18} />新增部员
</span>
</Button>
</div>
</div>
<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 || '—' },
{
key: 'group_name',
title: '所属工作组',
width: '260px',
render: (r) => <GroupBadges names={r.group_names || []} />,
},
{
key: 'total_hours',
title: '累计时长(小时)',
render: (r) => <span className="font-semibold text-primary-600">{Number(r.total_hours || 0).toFixed(2)}</span>,
},
{
key: 'op',
title: '操作',
width: '240px',
render: (r) => (
<div className="flex gap-1.5 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => openDetail(r)}>
<span className="inline-flex items-center gap-1.5">
<IconEye size={14} />详情
</span>
</Button>
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}>
<span className="inline-flex items-center gap-1.5">
<IconEdit size={14} />编辑
</span>
</Button>
<Button size="sm" variant="danger" onClick={() => onDelete(r)}>
<span className="inline-flex items-center gap-1.5">
<IconTrash size={14} />删除
</span>
</Button>
</div>
),
},
]}
data={filtered}
rowKey={(r) => r.id}
/>
<Modal
open={modalOpen}
title={editId ? '编辑部员' : '新增部员'}
onClose={() => setModalOpen(false)}
footer={
<>
<Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button>
<Button onClick={submit}>{editId ? '保存修改' : '添加部员'}</Button>
</>
}
>
<div className="space-y-4 max-w-lg">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1.5">班级 *</label>
<Input
value={form.class}
onChange={(e) => setForm({ ...form, class: e.target.value })}
placeholder="如:高三(1)班"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5">姓名 *</label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5">义工号</label>
<Input
value={form.volunteer_no}
onChange={(e) => setForm({ ...form, volunteer_no: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5">
所属工作组(可多选)
</label>
<GroupCheckboxes
groups={groups}
selected={form.group_ids}
onChange={(ids) => setForm({ ...form, group_ids: ids })}
/>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
</div>
</Modal>
<Modal
open={batchOpen}
title="批量添加部员"
onClose={() => setBatchOpen(false)}
width="max-w-5xl"
footer={
<>
<Button variant="secondary" onClick={() => setBatchOpen(false)}>取消</Button>
<Button onClick={submitBatch} disabled={!canSubmitBatch}>确认批量添加</Button>
</>
}
>
<div className="space-y-4">
<div className="card p-3 bg-primary-50/40 border-primary-200">
<div className="text-sm text-gray-700 leading-relaxed">
<div className="font-semibold text-gray-800 mb-1">格式说明(每行一条记录):</div>
<div>
格式:<code className="px-1.5 py-0.5 bg-white rounded border text-[#275354]">班级,姓名,义工号,工作组1/工作组2</code>
</div>
<div className="mt-1 muted">
· 分隔符支持:逗号(中英)、Tab、分号、竖线<br />
· 工作组分隔符支持:斜杠 / 、顿号 、竖线 | 空格<br />
· 义工号可为空;工作组请填写已创建的工作组名称,可多选<br />
· 支持直接从 Excel 表格复制整列粘贴
</div>
</div>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5">粘贴数据</label>
<Textarea
rows={6}
value={batchText}
placeholder={`示例:\n高三(1)班,张三,2024001,策划组/宣传组\n高三(2)班,李四,2024002,摄制组\n高三(1)班,王五,2024003,`}
onChange={(e) => setBatchText(e.target.value)}
/>
</div>
{batchErrors && <div className="text-sm text-red-600">{batchErrors}</div>}
{batchParsed.length > 0 && (
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-semibold text-gray-800">
解析结果(共 {batchParsed.length} 行,
<span className={canSubmitBatch ? 'text-green-700' : 'text-red-600'}>
错误 {batchParsed.filter((r) => r.errors.length > 0).length} 行
</span>
)
</div>
</div>
<div className="border border-gray-200 rounded-md overflow-hidden">
<div className="max-h-80 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr className="text-left text-gray-700">
<th className="px-3 py-2 font-semibold border-b">#</th>
<th className="px-3 py-2 font-semibold border-b">班级</th>
<th className="px-3 py-2 font-semibold border-b">姓名</th>
<th className="px-3 py-2 font-semibold border-b">义工号</th>
<th className="px-3 py-2 font-semibold border-b">工作组</th>
<th className="px-3 py-2 font-semibold border-b">校验</th>
</tr>
</thead>
<tbody>
{batchParsed.map((r) => (
<tr
key={r.idx}
className={r.errors.length ? 'bg-red-50' : 'hover:bg-gray-50'}
>
<td className="px-3 py-2 border-b text-gray-500 whitespace-nowrap">{r.idx + 1}</td>
<td className="px-3 py-2 border-b whitespace-nowrap">{r.class || <span className="text-red-600">—</span>}</td>
<td className="px-3 py-2 border-b whitespace-nowrap">{r.name || <span className="text-red-600">—</span>}</td>
<td className="px-3 py-2 border-b whitespace-nowrap">{r.volunteer_no || '—'}</td>
<td className="px-3 py-2 border-b">
<GroupBadges names={r.group_names} />
</td>
<td className="px-3 py-2 border-b align-top min-w-[160px]">
{r.errors.length === 0 ? (
<span className="text-green-700 font-semibold">✓ 通过</span>
) : (
<div className="text-red-600 text-xs leading-relaxed">
{r.errors.map((e, i) => (
<div key={i}>· {e}</div>
))}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</Modal>
<Modal
open={detailOpen}
title="部员详情"
onClose={() => setDetailOpen(false)}
width="max-w-3xl"
footer={
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
}
>
{detail && (
<div className="space-y-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="card p-3">
<div className="muted mb-1">姓名</div>
<div className="font-semibold text-lg text-gray-800">{detail.name}</div>
</div>
<div className="card p-3">
<div className="muted mb-1">班级</div>
<div className="font-semibold text-gray-800">{detail.class}</div>
</div>
<div className="card p-3">
<div className="muted mb-1">义工号</div>
<div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div>
</div>
<div className="card p-3 bg-primary-50/60">
<div className="muted mb-1">累计时长</div>
<div className="font-semibold text-lg text-primary-600">
{Number(detail.total_hours || 0).toFixed(2)}h
</div>
</div>
</div>
<div className="card p-3">
<div className="muted mb-2">所属工作组</div>
<GroupBadges names={detail.group_names || []} />
</div>
<div>
<h3 className="font-semibold mb-2 text-gray-800">
参与的活动({detail.activities.length} 次)
</h3>
<DataTable
columns={[
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium">{r.title}</span> },
{ key: 'time', title: '时间', render: (r: any) => formatDateRange(r.start_time, r.end_time) },
{
key: 'hours',
title: '时长',
render: (r: any) => (
<span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(2)}h</span>
),
},
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
]}
data={detail.activities}
rowKey={(r: any) => r.id}
/>
</div>
</div>
)}
</Modal>
</div>
);
}