Files
VolunteerManage-for-byw/client/src/pages/admin/Users.tsx
T
Trae Assistant 1a51a854a3
Deploy to Server / deploy (push) Canceled after 14m40s
feat(ui): 专业风UI改造 主色#275354 + Brand系统标题统一
- 全量替换游戏感Neo-Brutalist为政务专业风:圆角2-16px、薄灰边、柔阴影
- Tailwind主题:primary 10阶调色板 + Noto Sans SC主字体 + fade-in-up动画
- 全局CSS:浅灰F6F8F9背景 + 主色焦点环和滚动条 + card/chip工具类
- 品牌组件Brand.tsx:云图形截断防云盘旧字泄露 + 新标题组合
- 6基础组件 + 8页面:Layout深绿侧栏 Login卡片 Export去五色卡 Activities/Hours/Users 用户端Index/Dashboard
- 数据链路回归:u/2张三2h明细正确显示
- tsc无错误  vite build成功
2026-10-04 23:05:39 +08:00

164 lines
8.6 KiB
TypeScript

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';
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 };
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 flex items-start gap-2">
<div className="shrink-0 p-2 bg-primary-50 text-primary-600 rounded-md mt-0.5">
<IconUsers size={26} />
</div>
<div>
<h1 className="section-title">用户管理</h1>
<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>
</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-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) => (
<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>
</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-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>
<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>
<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>
{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-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>
<div className="card p-3">
<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>
<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>
</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} />
</div>
</div>
)}
</Modal>
</div>
);
}