Deploy to Server / deploy (push) Canceled after 14m40s
- 全量替换游戏感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成功
164 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|