From f42121d0bb493a34a10e46ff803651a8db46b291 Mon Sep 17 00:00:00 2001 From: Trae Assistant Date: Mon, 5 Oct 2026 08:08:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=83=A8=E5=91=98=E5=A4=9A=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E7=BB=84=E5=BD=92=E5=B1=9E=20+=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库:新增 user_groups 多对多关联表,启动时自动迁移旧 group_id 数据 - 后端 users:所有查询聚合多工作组信息,增删改接口支持 group_ids 数组;新增 POST /users/batch 批量添加接口(事务+逐行错误反馈) - 后端 export/hours/activities:全面兼容多工作组关联查询 - 前端 Users:新增/编辑改为工作组多选复选框;新增批量添加模态框(粘贴解析+实时校验表+提交汇总) - 前端列表/详情:工作组以多标签形式展示,搜索支持工作组名称 --- client/src/api/index.ts | 11 +- client/src/pages/admin/Users.tsx | 490 +++++++++++++++++++++++++++---- client/src/types/index.ts | 2 + server/src/db/index.ts | 20 ++ server/src/db/schema.sql | 8 + server/src/routes/activities.ts | 20 +- server/src/routes/export.ts | 75 ++++- server/src/routes/hours.ts | 6 +- server/src/routes/users.ts | 169 +++++++++-- 9 files changed, 705 insertions(+), 96 deletions(-) diff --git a/client/src/api/index.ts b/client/src/api/index.ts index de52bbb..91bc3bd 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -55,9 +55,16 @@ export const usersApi = { list: () => api.get('/users').then(r => r.data), search: (name: string) => api.get('/users/search', { params: { name } }).then(r => r.data), detail: (id: number) => api.get(`/users/${id}`).then(r => r.data), - create: (data: Partial) => api.post('/users', data).then(r => r.data), - update: (id: number, data: Partial) => api.put(`/users/${id}`, data).then(r => r.data), + create: (data: Partial & { group_ids?: number[] }) => api.post('/users', data).then(r => r.data), + update: (id: number, data: Partial & { 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 & { 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 = { diff --git a/client/src/pages/admin/Users.tsx b/client/src/pages/admin/Users.tsx index 7acad0b..e8133e3 100644 --- a/client/src/pages/admin/Users.tsx +++ b/client/src/pages/admin/Users.tsx @@ -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
暂无工作组,请先在工作组管理中创建
; + } + 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([]); @@ -24,46 +99,167 @@ export default function Users() { const [detailOpen, setDetailOpen] = useState(false); const [detail, setDetail] = useState(null); + const [batchOpen, setBatchOpen] = useState(false); + const [batchText, setBatchText] = useState(''); + const [batchErrors, setBatchErrors] = useState(''); + 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(); + 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() {

用户管理

-

管理部员信息,查看个人累计义工时长与参与活动明细

+

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

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