- 数据库:新增 user_groups 多对多关联表,启动时自动迁移旧 group_id 数据 - 后端 users:所有查询聚合多工作组信息,增删改接口支持 group_ids 数组;新增 POST /users/batch 批量添加接口(事务+逐行错误反馈) - 后端 export/hours/activities:全面兼容多工作组关联查询 - 前端 Users:新增/编辑改为工作组多选复选框;新增批量添加模态框(粘贴解析+实时校验表+提交汇总) - 前端列表/详情:工作组以多标签形式展示,搜索支持工作组名称
This commit is contained in:
@@ -55,9 +55,16 @@ export const usersApi = {
|
||||
list: () => api.get<UserItem[]>('/users').then(r => r.data),
|
||||
search: (name: string) => api.get<UserItem[]>('/users/search', { params: { name } }).then(r => r.data),
|
||||
detail: (id: number) => api.get<UserDetail>(`/users/${id}`).then(r => r.data),
|
||||
create: (data: Partial<UserItem>) => api.post('/users', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<UserItem>) => api.put(`/users/${id}`, data).then(r => r.data),
|
||||
create: (data: Partial<UserItem> & { group_ids?: number[] }) => api.post('/users', data).then(r => r.data),
|
||||
update: (id: number, data: Partial<UserItem> & { group_ids?: number[] }) => api.put(`/users/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => api.delete(`/users/${id}`).then(r => r.data),
|
||||
batch: (items: Array<Partial<UserItem> & { group_ids?: number[]; group_id?: number | null }>) =>
|
||||
api.post<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ idx: number; ok: boolean; id?: number; error?: string; item: any }>;
|
||||
}>('/users/batch', { items }).then(r => r.data),
|
||||
};
|
||||
|
||||
export const activitiesApi = {
|
||||
|
||||
@@ -1,16 +1,91 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import Input from '../../components/Input';
|
||||
import Select from '../../components/Select';
|
||||
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_id: number | null };
|
||||
const emptyForm: Form = { class: '', name: '', volunteer_no: '', group_id: null };
|
||||
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[]>([]);
|
||||
@@ -24,46 +99,167 @@ export default function Users() {
|
||||
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 load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [u, g] = await Promise.all([usersApi.list(), groupsApi.list()]);
|
||||
setList(u);
|
||||
const filled = u.map((x) => ({
|
||||
...x,
|
||||
group_ids: x.group_ids ?? [],
|
||||
group_names: x.group_names ?? [],
|
||||
}));
|
||||
setList(filled);
|
||||
setGroups(g);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const filtered = list.filter(u => {
|
||||
const filtered = list.filter((u) => {
|
||||
if (!keyword.trim()) return true;
|
||||
const k = keyword.trim().toLowerCase();
|
||||
return [u.name, u.class, u.volunteer_no, u.group_name || ''].some(s => String(s || '').toLowerCase().includes(k));
|
||||
const namesJoined = (u.group_names || []).join(' ');
|
||||
return [u.name, u.class, u.volunteer_no, namesJoined].some((s) =>
|
||||
String(s || '').toLowerCase().includes(k)
|
||||
);
|
||||
});
|
||||
|
||||
const openNew = () => { setForm(emptyForm); setEditId(null); setError(''); setModalOpen(true); };
|
||||
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_id: u.group_id });
|
||||
setEditId(u.id); setError(''); setModalOpen(true);
|
||||
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)));
|
||||
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); setDetailOpen(true); } catch (e) { alert(errMsg(e)); }
|
||||
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; }
|
||||
if (!form.class.trim() || !form.name.trim()) {
|
||||
setError('班级与姓名不能为空');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = { ...form, volunteer_no: form.volunteer_no.trim() || null, group_id: form.group_id || null };
|
||||
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)); }
|
||||
} 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>();
|
||||
for (const g of groups) groupMap.set(g.name.trim(), g.id);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split(/[,,\t|;;]/).map((p) => p.trim());
|
||||
const [cls, name, volunteer_no = '', groupsStr = ''] = parts;
|
||||
const errors: string[] = [];
|
||||
if (!cls) errors.push('缺少班级');
|
||||
if (!name) errors.push('缺少姓名');
|
||||
const groupIds: number[] = [];
|
||||
const groupNames: string[] = [];
|
||||
if (groupsStr) {
|
||||
const rawNames = groupsStr.split(/[/、|\s]+/).map((x) => x.trim()).filter(Boolean);
|
||||
for (const n of rawNames) {
|
||||
const gid = groupMap.get(n);
|
||||
if (gid === undefined) {
|
||||
errors.push(`工作组「${n}」不存在`);
|
||||
} else {
|
||||
if (!groupIds.includes(gid)) {
|
||||
groupIds.push(gid);
|
||||
groupNames.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
idx: i,
|
||||
class: cls || '',
|
||||
name: name || '',
|
||||
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,
|
||||
}));
|
||||
const result = await usersApi.batch(items);
|
||||
setBatchErrors('');
|
||||
const msg = `批量添加完成:共 ${result.total} 条,成功 ${result.success} 条,失败 ${result.failed} 条`;
|
||||
if (result.failed === 0) {
|
||||
alert(msg);
|
||||
setBatchOpen(false);
|
||||
setBatchText('');
|
||||
load();
|
||||
} else {
|
||||
const failLines = result.results
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => `第${r.idx + 1}行 [${r.item.class} ${r.item.name}]:${r.error}`)
|
||||
.join('\n');
|
||||
alert(`${msg}\n\n失败明细:\n${failLines}`);
|
||||
load();
|
||||
}
|
||||
} catch (e) {
|
||||
setBatchErrors(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,13 +270,29 @@ export default function Users() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="section-title">用户管理</h1>
|
||||
<p className="muted mt-1">管理部员信息,查看个人累计义工时长与参与活动明细</p>
|
||||
<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" />
|
||||
<Button onClick={openNew}><span className="inline-flex items-center gap-2"><IconPlus size={18} />新增部员</span></Button>
|
||||
<Input
|
||||
placeholder="搜索 姓名/班级/义工号/工作组..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="md:max-w-md"
|
||||
/>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<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>
|
||||
@@ -88,72 +300,244 @@ export default function Users() {
|
||||
{ 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: '所属工作组', render: (r) => r.group_name || '—' },
|
||||
{ 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) => (
|
||||
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>
|
||||
<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-md">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<Select value={String(form.group_id || '')} onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">-- 不设置 --</option>
|
||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</Select>
|
||||
<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={detailOpen} title="部员详情" onClose={() => setDetailOpen(false)} width="max-w-3xl"
|
||||
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}>
|
||||
<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 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 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 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 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} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,9 @@ export interface UserItem {
|
||||
name: string;
|
||||
volunteer_no: string | null;
|
||||
group_id: number | null;
|
||||
group_ids: number[];
|
||||
group_name?: string | null;
|
||||
group_names: string[];
|
||||
total_hours?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user