feat: 部员多工作组归属 + 批量添加功能
Deploy to Server / deploy (push) Successful in 1m59s

- 数据库:新增 user_groups 多对多关联表,启动时自动迁移旧 group_id 数据
- 后端 users:所有查询聚合多工作组信息,增删改接口支持 group_ids 数组;新增 POST /users/batch 批量添加接口(事务+逐行错误反馈)
- 后端 export/hours/activities:全面兼容多工作组关联查询
- 前端 Users:新增/编辑改为工作组多选复选框;新增批量添加模态框(粘贴解析+实时校验表+提交汇总)
- 前端列表/详情:工作组以多标签形式展示,搜索支持工作组名称
This commit is contained in:
Trae Assistant
2026-10-05 08:08:15 +08:00
parent d62f2e2ba4
commit f42121d0bb
9 changed files with 705 additions and 96 deletions
+9 -2
View File
@@ -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 = {
+437 -53
View File
@@ -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>
)}
+2
View File
@@ -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;
}