feat: 初始化义工时长管理系统
- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG) - 用户端:姓名查询个人义工时长与活动记录 - 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite) - 支持 Docker 部署,附带 Gitea Actions 自动部署工作流 - 响应式界面,兼容移动端与 PC 端 - 默认管理员账号:admin / admin123
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { useEffect, 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 { groupsApi, usersApi, errMsg } from '../../api';
|
||||
import { formatDateRange } from '../../utils/export';
|
||||
import type { UserItem, UserDetail, WorkGroup } from '../../types';
|
||||
|
||||
type Form = { class: string; name: string; volunteer_no: string; group_id: number | null };
|
||||
const emptyForm: Form = { class: '', name: '', volunteer_no: '', group_id: null };
|
||||
|
||||
export default function Users() {
|
||||
const [list, setList] = useState<UserItem[]>([]);
|
||||
const [groups, setGroups] = useState<WorkGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<Form>(emptyForm);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<UserDetail | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [u, g] = await Promise.all([usersApi.list(), groupsApi.list()]);
|
||||
setList(u);
|
||||
setGroups(g);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
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 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);
|
||||
};
|
||||
const onDelete = (u: UserItem) => {
|
||||
if (!confirm(`确认删除部员「${u.class} ${u.name}」?其所有时长登记也会一并删除。`)) return;
|
||||
usersApi.remove(u.id).then(load).catch(e => alert(errMsg(e)));
|
||||
};
|
||||
const openDetail = async (u: UserItem) => {
|
||||
try { const d = await usersApi.detail(u.id); setDetail(d); setDetailOpen(true); } catch (e) { alert(errMsg(e)); }
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
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 };
|
||||
if (editId) await usersApi.update(editId, payload);
|
||||
else await usersApi.create(payload);
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch (e) { setError(errMsg(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<h1 className="text-xl md:text-2xl font-black mb-1">👥 用户管理</h1>
|
||||
<p className="text-sm text-gray-500">管理部员信息,查看个人累计义工时长与参与活动明细</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-black 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}>+ 新增部员</Button>
|
||||
</div>
|
||||
|
||||
<DataTable<UserItem>
|
||||
columns={[
|
||||
{ key: 'class', title: '班级', width: '120px', render: (r) => <span className="whitespace-nowrap">{r.class}</span> },
|
||||
{ key: 'name', title: '姓名', render: (r) => <span className="font-bold">{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-black">{Number(r.total_hours || 0).toFixed(2)}</span> },
|
||||
{
|
||||
key: 'op', title: '操作', width: '220px', render: (r) => (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Button size="sm" variant="secondary" onClick={() => openDetail(r)}>详情</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => onDelete(r)}>删除</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">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-sm font-bold 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-bold 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-bold 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-bold 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>
|
||||
{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>}>
|
||||
{detail && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">姓名</div><div className="font-black text-lg">{detail.name}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">班级</div><div className="font-bold">{detail.class}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">义工号</div><div className="font-bold">{detail.volunteer_no || '—'}</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">累计时长</div><div className="font-black text-lg text-black">{Number(detail.total_hours || 0).toFixed(2)}h</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold mb-2">参与的活动({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-bold">{Number(r.hours || 0).toFixed(2)}h</span> },
|
||||
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
|
||||
]} data={detail.activities} rowKey={(r: any) => r.id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user