Deploy to Server / deploy (push) Successful in 1m37s
- 后端 /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 通过
698 lines
26 KiB
TypeScript
698 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|