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 = {
+434 -50
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={[
<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: '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} />
]}
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;
}
+20
View File
@@ -105,6 +105,26 @@ export async function initDb() {
const schema = fs.readFileSync(schemaPath, 'utf-8');
try { d.execCompat(schema); d.save(); } catch (e) { console.error('Schema init error', e); }
try {
const ugExists = d.prepareCompat(`
SELECT name FROM sqlite_master WHERE type='table' AND name='user_groups'
`).get();
if (ugExists) {
const legacyRows = d.prepareCompat(`
SELECT COUNT(*) AS c FROM users WHERE group_id IS NOT NULL
AND id NOT IN (SELECT DISTINCT user_id FROM user_groups)
`).get();
if (legacyRows && legacyRows.c > 0) {
d.prepareCompat(`
INSERT OR IGNORE INTO user_groups (user_id, group_id)
SELECT id, group_id FROM users WHERE group_id IS NOT NULL
`).run();
d.save();
console.log(`[DB] Migrated ${legacyRows.c} users from legacy group_id to user_groups`);
}
}
} catch (e) { console.error('User_groups migration error', e); }
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
if (!adminCount || adminCount.c === 0) {
const hash = bcrypt.hashSync('admin123', 10);
+8
View File
@@ -13,6 +13,14 @@ CREATE TABLE IF NOT EXISTS users (
UNIQUE(class, name)
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES work_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
+15 -5
View File
@@ -103,17 +103,27 @@ router.get('/:id/participants', (req: Request, res: Response) => {
let users: any[];
if (groupIds.length === 0) {
users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
SELECT u.id, u.class, u.name, u.volunteer_no,
(SELECT GROUP_CONCAT(g.name)
FROM user_groups ug2
LEFT JOIN work_groups g ON ug2.group_id = g.id
WHERE ug2.user_id = u.id) AS group_name
FROM users u
GROUP BY u.id
ORDER BY u.class, u.name
`).all();
} else {
const ph = groupIds.map(() => '?').join(',');
users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
SELECT u.id, u.class, u.name, u.volunteer_no,
(SELECT GROUP_CONCAT(g.name)
FROM user_groups ug2
LEFT JOIN work_groups g ON ug2.group_id = g.id
WHERE ug2.user_id = u.id) AS group_name
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE u.group_id IN (${ph}) OR u.group_id IS NULL
WHERE u.id IN (SELECT DISTINCT user_id FROM user_groups WHERE group_id IN (${ph}))
OR u.id NOT IN (SELECT DISTINCT user_id FROM user_groups)
GROUP BY u.id
ORDER BY u.class, u.name
`).all(...groupIds);
}
+59 -16
View File
@@ -4,17 +4,34 @@ import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
function withGroupNames(rows: any[]): any[] {
return rows.map((r) => {
const names: string[] = [];
if (r._group_names_csv) {
for (const s of String(r._group_names_csv).split(',')) {
if (s && s.trim()) names.push(s.trim());
}
}
return { ...r, group_name: names.join('、') || null };
});
}
router.post('/', authMiddleware, (req: AuthRequest, res) => {
const { include_users, include_activities, include_hours, include_user_totals, include_user_activities, include_activity_matrix } = req.body || {};
const db = getDb();
const result: any = {};
if (include_users) {
result.users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
const rows = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
GROUP_CONCAT(g.name) AS _group_names_csv
FROM users u
LEFT JOIN user_groups ug ON u.id = ug.user_id
LEFT JOIN work_groups g ON ug.group_id = g.id
GROUP BY u.id
ORDER BY u.class, u.name
`).all();
result.users = withGroupNames(rows).map(({ _group_names_csv: _, ...rest }) => rest);
}
if (include_activities) {
result.activities = db.prepareCompat(`
@@ -27,35 +44,49 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
`).all();
}
if (include_hours) {
result.hours = db.prepareCompat(`
const rows = db.prepareCompat(`
SELECT h.hours, h.remark, h.created_at,
a.title AS activity_title, a.start_time, a.end_time,
u.class, u.name, u.volunteer_no, g.name AS group_name
u.class, u.name, u.volunteer_no,
(SELECT GROUP_CONCAT(g2.name)
FROM user_groups ug2
LEFT JOIN work_groups g2 ON ug2.group_id = g2.id
WHERE ug2.user_id = u.id) AS _group_names_csv
FROM hours h
JOIN activities a ON h.activity_id = a.id
JOIN users u ON h.user_id = u.id
LEFT JOIN work_groups g ON u.group_id = g.id
ORDER BY a.start_time DESC, u.class, u.name
`).all();
result.hours = withGroupNames(rows).map(({ _group_names_csv: _, ...rest }) => rest);
}
if (include_user_totals) {
result.user_totals = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name,
const rows = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
(SELECT GROUP_CONCAT(g2.name)
FROM user_groups ug2
LEFT JOIN work_groups g2 ON ug2.group_id = g2.id
WHERE ug2.user_id = u.id) AS _group_names_csv,
COALESCE(SUM(h.hours), 0) AS total_hours,
COUNT(DISTINCT h.activity_id) AS activity_count
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
LEFT JOIN hours h ON h.user_id = u.id
GROUP BY u.id
ORDER BY total_hours DESC, u.class, u.name
`).all();
result.user_totals = withGroupNames(rows).map(({ _group_names_csv: _, ...rest }) => rest);
}
if (include_user_activities) {
const users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
const userRows = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
(SELECT GROUP_CONCAT(g2.name)
FROM user_groups ug2
LEFT JOIN work_groups g2 ON ug2.group_id = g2.id
WHERE ug2.user_id = u.id) AS _group_names_csv
FROM users u
GROUP BY u.id
ORDER BY u.class, u.name
`).all() as any[];
const users = withGroupNames(userRows).map(({ _group_names_csv: _, ...rest }) => rest);
result.user_activities = users.map((u) => {
const acts = db.prepareCompat(`
SELECT a.title, a.start_time, a.end_time, h.hours, h.remark
@@ -69,11 +100,17 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
const activities = db.prepareCompat(`
SELECT id, title, start_time FROM activities ORDER BY start_time DESC, id DESC
`).all() as any[];
const users = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
const userRows = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
(SELECT GROUP_CONCAT(g2.name)
FROM user_groups ug2
LEFT JOIN work_groups g2 ON ug2.group_id = g2.id
WHERE ug2.user_id = u.id) AS _group_names_csv
FROM users u
GROUP BY u.id
ORDER BY u.class, u.name
`).all() as any[];
const users = withGroupNames(userRows);
const hoursRows = db.prepareCompat(`
SELECT user_id, activity_id, hours FROM hours
`).all() as any[];
@@ -85,7 +122,13 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
result.activity_matrix = {
activities,
users: users.map((u) => {
const row: Record<string, any> = { id: u.id, class: u.class, name: u.name, volunteer_no: u.volunteer_no, group_name: u.group_name };
const row: Record<string, any> = {
id: u.id,
class: u.class,
name: u.name,
volunteer_no: u.volunteer_no,
group_name: u.group_name,
};
for (const a of activities) {
row[String(a.id)] = Number((matrix[`${u.id}|${a.id}`] || 0).toFixed(2));
}
+4 -2
View File
@@ -44,10 +44,12 @@ router.get('/activity/:id', (req: Request, res: Response) => {
const rows = db.prepareCompat(`
SELECT h.id, h.hours, h.remark, h.created_at,
u.id AS user_id, u.class, u.name, u.volunteer_no,
g.name AS group_name
(SELECT GROUP_CONCAT(g.name)
FROM user_groups ug
LEFT JOIN work_groups g ON ug.group_id = g.id
WHERE ug.user_id = u.id) AS group_name
FROM hours h
JOIN users u ON h.user_id = u.id
LEFT JOIN work_groups g ON u.group_id = g.id
WHERE h.activity_id = ?
ORDER BY u.class, u.name
`).all(id);
+151 -18
View File
@@ -4,44 +4,77 @@ import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
function parseCsvIds(s: any): number[] {
if (!s) return [];
return String(s).split(',').map(x => Number(x.trim())).filter(n => !isNaN(n) && n > 0);
}
function parseCsvStrings(s: any): string[] {
if (!s) return [];
return String(s).split(',').filter(x => x && x.trim().length > 0);
}
function enrichUser(u: any) {
if (!u) return u;
return {
...u,
group_ids: parseCsvIds(u.group_ids_csv),
group_names: parseCsvStrings(u.group_names_csv),
group_name: parseCsvStrings(u.group_names_csv).join('、') || null,
group_id: parseCsvIds(u.group_ids_csv)[0] ?? null,
};
}
router.get('/', authMiddleware, (req: AuthRequest, res: Response) => {
const db = getDb();
const list = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, u.group_id,
g.name AS group_name,
const rows = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
GROUP_CONCAT(g.id) AS group_ids_csv,
GROUP_CONCAT(g.name) AS group_names_csv,
COALESCE((SELECT SUM(h.hours) FROM hours h WHERE h.user_id = u.id), 0) AS total_hours
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
LEFT JOIN user_groups ug ON u.id = ug.user_id
LEFT JOIN work_groups g ON ug.group_id = g.id
GROUP BY u.id
ORDER BY u.class ASC, u.name ASC
`).all();
res.json(list);
res.json(rows.map(enrichUser));
});
router.get('/search', (req: Request, res: Response) => {
const name = String(req.query.name || '').trim();
if (!name) return res.json([]);
const db = getDb();
const list = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
const rows = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
GROUP_CONCAT(g.id) AS group_ids_csv,
GROUP_CONCAT(g.name) AS group_names_csv
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
LEFT JOIN user_groups ug ON u.id = ug.user_id
LEFT JOIN work_groups g ON ug.group_id = g.id
WHERE u.name LIKE ?
GROUP BY u.id
ORDER BY u.class ASC
`).all(`%${name}%`);
res.json(list);
res.json(rows.map(enrichUser));
});
router.get('/:id', (req: Request, res: Response) => {
const id = Number(req.params.id);
const db = getDb();
const user = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no, u.group_id, g.name AS group_name,
const raw = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
GROUP_CONCAT(g.id) AS group_ids_csv,
GROUP_CONCAT(g.name) AS group_names_csv,
COALESCE((SELECT SUM(h.hours) FROM hours h WHERE h.user_id = u.id), 0) AS total_hours
FROM users u
LEFT JOIN work_groups g ON u.group_id = g.id
LEFT JOIN user_groups ug ON u.id = ug.user_id
LEFT JOIN work_groups g ON ug.group_id = g.id
WHERE u.id = ?
GROUP BY u.id
`).get(id) as any;
if (!user) return res.status(404).json({ error: '部员不存在' });
if (!raw) return res.status(404).json({ error: '部员不存在' });
const user = enrichUser(raw);
const activities = db.prepareCompat(`
SELECT a.id, a.title, a.start_time, a.end_time, h.hours, h.remark
FROM hours h
@@ -52,18 +85,40 @@ router.get('/:id', (req: Request, res: Response) => {
res.json({ ...user, activities });
});
function bindUserGroups(db: any, userId: number, groupIds: number[] | undefined) {
db.prepareCompat('DELETE FROM user_groups WHERE user_id = ?').run(userId);
const insertUG = db.prepareCompat('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)');
if (Array.isArray(groupIds)) {
const seen = new Set<number>();
for (const gid of groupIds) {
const n = Number(gid);
if (!isNaN(n) && n > 0 && !seen.has(n)) {
seen.add(n);
insertUG.run(userId, n);
}
}
}
}
router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
const { class: cls, name, volunteer_no, group_id } = req.body || {};
const { class: cls, name, volunteer_no, group_id, group_ids } = req.body || {};
if (!cls || !name || !String(cls).trim() || !String(name).trim()) {
return res.status(400).json({ error: '班级和姓名不能为空' });
}
const groups = Array.isArray(group_ids) ? group_ids : (group_id ? [group_id] : []);
const db = getDb();
try {
db.execCompat('BEGIN');
const firstGroup = groups[0] ?? null;
const info = db.prepareCompat('INSERT INTO users (class, name, volunteer_no, group_id) VALUES (?,?,?,?)')
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, group_id || null);
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, firstGroup);
const userId = Number(info.lastInsertRowid);
bindUserGroups(db, userId, groups);
db.execCompat('COMMIT');
db.save();
res.json({ id: info.lastInsertRowid });
res.json({ id: userId });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '该班级下已存在同名部员' });
}
@@ -73,17 +128,23 @@ router.post('/', authMiddleware, (req: AuthRequest, res: Response) => {
router.put('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
const id = Number(req.params.id);
const { class: cls, name, volunteer_no, group_id } = req.body || {};
const { class: cls, name, volunteer_no, group_id, group_ids } = req.body || {};
if (!cls || !name || !String(cls).trim() || !String(name).trim()) {
return res.status(400).json({ error: '班级和姓名不能为空' });
}
const groups = Array.isArray(group_ids) ? group_ids : (group_id ? [group_id] : []);
const db = getDb();
try {
db.execCompat('BEGIN');
const firstGroup = groups[0] ?? null;
db.prepareCompat('UPDATE users SET class=?, name=?, volunteer_no=?, group_id=? WHERE id=?')
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, group_id || null, id);
.run(String(cls).trim(), String(name).trim(), volunteer_no || null, firstGroup, id);
bindUserGroups(db, id, groups);
db.execCompat('COMMIT');
db.save();
res.json({ ok: true });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
if (String(e.message).includes('UNIQUE')) {
return res.status(400).json({ error: '该班级下已存在同名部员' });
}
@@ -99,4 +160,76 @@ router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
res.json({ ok: true });
});
interface BatchItem {
class: string;
name: string;
volunteer_no?: string | null;
group_ids?: number[];
group_id?: number | null;
}
interface BatchResult {
total: number;
success: number;
failed: number;
results: Array<{ idx: number; ok: boolean; id?: number; error?: string; item: BatchItem }>;
}
router.post('/batch', authMiddleware, (req: AuthRequest, res: Response) => {
const { items } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: '批量数据不能为空' });
}
const db = getDb();
const results: BatchResult['results'] = [];
let success = 0;
let failed = 0;
try {
db.execCompat('BEGIN');
const insertUser = db.prepareCompat('INSERT INTO users (class, name, volunteer_no, group_id) VALUES (?,?,?,?)');
const insertUG = db.prepareCompat('INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)');
for (let i = 0; i < items.length; i++) {
const raw = items[i] || {};
const cls = String(raw.class || '').trim();
const name = String(raw.name || '').trim();
const volunteer_no = raw.volunteer_no ? String(raw.volunteer_no).trim() || null : null;
if (!cls || !name) {
failed++;
results.push({ idx: i, ok: false, error: '班级和姓名不能为空', item: raw });
continue;
}
const groups: number[] = [];
if (Array.isArray(raw.group_ids)) {
for (const g of raw.group_ids) {
const n = Number(g);
if (!isNaN(n) && n > 0 && !groups.includes(n)) groups.push(n);
}
} else if (raw.group_id) {
const n = Number(raw.group_id);
if (!isNaN(n) && n > 0) groups.push(n);
}
const firstGroup = groups[0] ?? null;
try {
const info = insertUser.run(cls, name, volunteer_no, firstGroup);
const userId = Number(info.lastInsertRowid);
for (const gid of groups) insertUG.run(userId, gid);
success++;
results.push({ idx: i, ok: true, id: userId, item: raw });
} catch (e: any) {
failed++;
let err = e?.message || '添加失败';
if (String(err).includes('UNIQUE')) err = '该班级下已存在同名部员';
results.push({ idx: i, ok: false, error: err, item: raw });
}
}
db.execCompat('COMMIT');
db.save();
res.json({ total: items.length, success, failed, results } as BatchResult);
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
res.status(500).json({ error: e?.message || '批量处理失败' });
}
});
export default router;