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,187 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import DataTable from '../../components/DataTable';
|
||||
import Modal from '../../components/Modal';
|
||||
import Input from '../../components/Input';
|
||||
import Select from '../../components/Select';
|
||||
import Textarea from '../../components/Textarea';
|
||||
import { activitiesApi, groupsApi, errMsg } from '../../api';
|
||||
import { formatDateRange } from '../../utils/export';
|
||||
import type { ActivityItem, WorkGroup } from '../../types';
|
||||
|
||||
type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] };
|
||||
const emptyForm: Form = { title: '', content: '', start_time: '', end_time: '', groups: [] };
|
||||
|
||||
function toLocalInput(d: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export default function Activities() {
|
||||
const [list, setList] = useState<ActivityItem[]>([]);
|
||||
const [groups, setGroups] = useState<WorkGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form, setForm] = useState<Form>(emptyForm);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [groupsOpen, setGroupsOpen] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [groupsError, setGroupsError] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [acts, gs] = await Promise.all([activitiesApi.list(), groupsApi.list()]);
|
||||
setList(acts);
|
||||
setGroups(gs);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const openNew = () => {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 2 * 3600 * 1000);
|
||||
setForm({ ...emptyForm, start_time: toLocalInput(now), end_time: toLocalInput(end) });
|
||||
setEditId(null);
|
||||
setError('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
const openEdit = async (a: ActivityItem) => {
|
||||
try {
|
||||
const d = await activitiesApi.detail(a.id);
|
||||
setForm({ title: d.title, content: d.content || '', start_time: d.start_time.replace(' ', 'T').slice(0, 16), end_time: d.end_time.replace(' ', 'T').slice(0, 16), groups: d.groups || [] });
|
||||
setEditId(a.id);
|
||||
setError('');
|
||||
setModalOpen(true);
|
||||
} catch (e) { alert(errMsg(e)); }
|
||||
};
|
||||
const onDelete = async (a: ActivityItem) => {
|
||||
if (!confirm(`确认删除活动「${a.title}」?该活动的时长登记也会删除。`)) return;
|
||||
try { await activitiesApi.remove(a.id); load(); } catch (e) { alert(errMsg(e)); }
|
||||
};
|
||||
|
||||
const toggleGroup = (id: number) => {
|
||||
setForm(f => f.groups.includes(id) ? { ...f, groups: f.groups.filter(g => g !== id) } : { ...f, groups: [...f.groups, id] });
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.title.trim()) { setError('活动标题不能为空'); return; }
|
||||
if (!form.start_time || !form.end_time) { setError('活动时间不能为空'); return; }
|
||||
setError('');
|
||||
try {
|
||||
const payload = { ...form, start_time: form.start_time.replace('T', ' '), end_time: form.end_time.replace('T', ' ') };
|
||||
if (editId) await activitiesApi.update(editId, payload);
|
||||
else await activitiesApi.create(payload);
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch (e) { setError(errMsg(e)); }
|
||||
};
|
||||
|
||||
const addGroup = async () => {
|
||||
const n = newGroupName.trim();
|
||||
if (!n) { setGroupsError('请输入名称'); return; }
|
||||
try {
|
||||
await groupsApi.create(n);
|
||||
setNewGroupName('');
|
||||
setGroupsError('');
|
||||
const gs = await groupsApi.list();
|
||||
setGroups(gs);
|
||||
} catch (e) { setGroupsError(errMsg(e)); }
|
||||
};
|
||||
const removeGroup = async (g: WorkGroup) => {
|
||||
if (!confirm(`删除工作组「${g.name}」?已关联的部员会解除关联。`)) return;
|
||||
try { await groupsApi.remove(g.id); const gs = await groupsApi.list(); setGroups(gs); } catch (e) { alert(errMsg(e)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-start flex-wrap gap-3 mb-5">
|
||||
<div>
|
||||
<h1 className="text-xl md:text-2xl font-black mb-1">📅 活动设置</h1>
|
||||
<p className="text-sm text-gray-500">发布、管理活动,并设置参与的工作组</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="secondary" onClick={() => setGroupsOpen(true)}>管理工作组</Button>
|
||||
<Button onClick={openNew}>+ 新建活动</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable<ActivityItem>
|
||||
columns={[
|
||||
{ key: 'title', title: '活动标题', width: '26%', render: (r) => <span className="font-bold">{r.title}</span> },
|
||||
{ key: 'time', title: '活动时间', render: (r) => formatDateRange(r.start_time, r.end_time) },
|
||||
{ key: 'groups', title: '参与工作组', render: (r) => <span className="text-sm">{(r.groups_name_list || []).join('、') || '全部'}</span> },
|
||||
{ key: 'created_at', title: '创建时间', width: '18%', render: (r) => String(r.created_at || '').slice(0, 16) },
|
||||
{
|
||||
key: 'op', title: '操作', width: '180px', render: (r) => (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => onDelete(r)}>删除</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
]}
|
||||
data={list}
|
||||
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">
|
||||
<div><label className="block text-sm font-bold mb-1.5">活动标题 *</label>
|
||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:2026 元旦联欢志愿服务" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div><label className="block text-sm font-bold mb-1.5">开始时间 *</label>
|
||||
<Input type="datetime-local" value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} />
|
||||
</div>
|
||||
<div><label className="block text-sm font-bold mb-1.5">结束时间 *</label>
|
||||
<Input type="datetime-local" value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div><label className="block text-sm font-bold mb-1.5">活动内容</label>
|
||||
<Textarea rows={3} value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1.5">参与的工作组(不选则默认全部部员可选)</label>
|
||||
{groups.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 p-3 border-2 border-dashed border-gray-300">暂无工作组,可先去「管理工作组」添加</div>
|
||||
) : (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{groups.map((g) => (
|
||||
<label key={g.id} className={`flex items-center gap-2 px-3 py-2 border-2 cursor-pointer ${form.groups.includes(g.id) ? 'border-black bg-black text-white' : 'border-gray-300 bg-white text-black'}`}>
|
||||
<input type="checkbox" className="hidden" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
|
||||
<span className="text-sm font-medium">{g.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={groupsOpen} title="工作组管理" onClose={() => setGroupsOpen(false)}
|
||||
footer={<Button variant="secondary" onClick={() => setGroupsOpen(false)}>关闭</Button>}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="输入新工作组名称,如:策划部" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
|
||||
<Button onClick={addGroup}>添加</Button>
|
||||
</div>
|
||||
{groupsError && <div className="text-sm text-red-600">{groupsError}</div>}
|
||||
<DataTable<WorkGroup>
|
||||
columns={[
|
||||
{ key: 'name', title: '工作组名称', render: (r) => <span className="font-medium">{r.name}</span> },
|
||||
{ key: 'op', title: '操作', width: '100px', render: (r) => <Button size="sm" variant="danger" onClick={() => removeGroup(r)}>删除</Button> }
|
||||
]}
|
||||
data={groups}
|
||||
rowKey={(r) => r.id}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import { exportApi, errMsg } from '../../api';
|
||||
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export';
|
||||
import type { ExportResult } from '../../types';
|
||||
|
||||
type Opts = {
|
||||
include_users: boolean;
|
||||
include_activities: boolean;
|
||||
include_hours: boolean;
|
||||
include_user_totals: boolean;
|
||||
include_user_activities: boolean;
|
||||
};
|
||||
|
||||
const defaultOpts: Opts = {
|
||||
include_users: true,
|
||||
include_activities: true,
|
||||
include_hours: true,
|
||||
include_user_totals: true,
|
||||
include_user_activities: false,
|
||||
};
|
||||
|
||||
export default function Export() {
|
||||
const [opts, setOpts] = useState<Opts>(defaultOpts);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preview, setPreview] = useState<ExportResult | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = (k: keyof Opts) => setOpts(o => ({ ...o, [k]: !o[k] }));
|
||||
const anySelected = Object.values(opts).some(Boolean);
|
||||
|
||||
const load = async () => {
|
||||
if (!anySelected) { alert('请至少选择一项导出内容'); return; }
|
||||
setLoading(true); setPreview(null);
|
||||
try {
|
||||
const d = await exportApi.data(opts);
|
||||
setPreview(d);
|
||||
} catch (e) {
|
||||
alert(errMsg(e));
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const doExcel = () => {
|
||||
if (!preview) return alert('请先生成预览');
|
||||
const sheets: Array<{ name: string; data: any[] }> = [];
|
||||
if (preview.users?.length) sheets.push({ name: '部员名册', data: preview.users.map((u, i) => ({ '序号': i + 1, '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '所属工作组': u.group_name || '—' })) });
|
||||
if (preview.activities?.length) sheets.push({ name: '活动列表', data: preview.activities.map((a, i) => ({ '序号': i + 1, '活动标题': a.title, '内容': a.content || '', '开始时间': a.start_time, '结束时间': a.end_time, '参与工作组': a.groups_name || '', '创建时间': (a.created_at || '').slice(0, 16) })) });
|
||||
if (preview.hours?.length) sheets.push({ name: '全部时长明细', data: preview.hours.map((h: any, i: number) => ({ '序号': i + 1, '班级': h.class, '姓名': h.name, '义工号': h.volunteer_no || '—', '工作组': h.group_name || '—', '活动名称': h.activity_title, '活动时间': formatDateRange(h.start_time, h.end_time), '时长(小时)': Number(h.hours || 0).toFixed(2), '备注': h.remark || '—', '登记时间': (h.created_at || '').slice(0, 16) })) });
|
||||
if (preview.user_totals?.length) sheets.push({ name: '部员总时长', data: preview.user_totals.map((u, i) => ({ '排名': i + 1, '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '参与活动数': u.activity_count, '累计时长(小时)': Number(u.total_hours || 0).toFixed(2) })) });
|
||||
if (preview.user_activities?.length) {
|
||||
const flat: any[] = [];
|
||||
preview.user_activities.forEach(u => {
|
||||
if (u.activities.length === 0) flat.push({ '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '活动名称': '(未参与任何活动)', '时间': '', '时长(小时)': '', '备注': '' });
|
||||
u.activities.forEach(a => flat.push({ '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '活动名称': a.title, '时间': formatDateRange(a.start_time, a.end_time), '时长(小时)': Number(a.hours || 0).toFixed(2), '备注': a.remark || '—' }));
|
||||
});
|
||||
sheets.push({ name: '部员活动明细', data: flat });
|
||||
}
|
||||
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0,10)}.xlsx`);
|
||||
};
|
||||
|
||||
const doImage = async () => {
|
||||
if (!previewRef.current) return;
|
||||
await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0,10)}.png`);
|
||||
};
|
||||
|
||||
const Row = ({ title, desc, value, onChange }: { title: string; desc: string; value: boolean; onChange: () => void }) => (
|
||||
<label className={`block p-4 border-2 cursor-pointer transition ${value ? 'border-black bg-gray-50' : 'border-gray-200 bg-white hover:border-gray-400'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<input type="checkbox" className="mt-1 w-4 h-4" checked={value} onChange={onChange} />
|
||||
<div className="flex-1">
|
||||
<div className="font-bold text-black">{title}</div>
|
||||
<div className="text-sm text-gray-600 mt-0.5">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
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">勾选需要导出的内容,预览后一键导出为 Excel 表格或 PNG 图片</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-black p-4 md:p-5 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||
<Row title="① 部员名册" desc="导出所有部员的班级、姓名、义工号与所属工作组" value={opts.include_users} onChange={() => toggle('include_users')} />
|
||||
<Row title="② 活动列表" desc="导出所有活动的标题、时间、参与工作组" value={opts.include_activities} onChange={() => toggle('include_activities')} />
|
||||
<Row title="③ 全部时长明细" desc="所有部员在所有活动中的时长登记记录(含备注)" value={opts.include_hours} onChange={() => toggle('include_hours')} />
|
||||
<Row title="④ 部员总义工时长" desc="所有部员各自的累计总时长与活动数(按时长降序排名)" value={opts.include_user_totals} onChange={() => toggle('include_user_totals')} />
|
||||
<Row title="⑤ 部员参加的活动" desc="每位部员参与过的所有活动明细(活动较多时文件较大)" value={opts.include_user_activities} onChange={() => toggle('include_user_activities')} />
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>{loading ? '生成中...' : '生成预览'}</Button>
|
||||
<Button variant="secondary" onClick={doExcel} disabled={!preview}>📊 导出 Excel</Button>
|
||||
<Button onClick={doImage} disabled={!preview}>🖼 导出为图片</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div ref={previewRef} className="bg-white border-2 border-gray-200 p-3 md:p-5 space-y-6">
|
||||
<div className="border-b-2 border-black pb-3 text-center hidden md:block">
|
||||
<div className="text-xl font-black">义工数据汇总导出</div>
|
||||
<div className="text-sm text-gray-600 mt-1">生成时间:{new Date().toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
|
||||
{preview.users?.length && (
|
||||
<section>
|
||||
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">① 部员名册({preview.users.length} 人)</h3>
|
||||
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '—', u.group_name || '—'])} />
|
||||
</section>
|
||||
)}
|
||||
{preview.activities?.length && (
|
||||
<section>
|
||||
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">② 活动列表({preview.activities.length} 个)</h3>
|
||||
<MiniTable heads={['序号', '活动标题', '时间', '参与工作组']} rows={preview.activities.map((a, i) => [i + 1, a.title, formatDateRange(a.start_time, a.end_time), a.groups_name || '全部'])} />
|
||||
</section>
|
||||
)}
|
||||
{preview.hours?.length && (
|
||||
<section>
|
||||
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">③ 全部时长明细({preview.hours.length} 条)</h3>
|
||||
<MiniTable heads={['班级', '姓名', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any) => [h.class, h.name, h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '—'])} />
|
||||
</section>
|
||||
)}
|
||||
{preview.user_totals?.length && (
|
||||
<section>
|
||||
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">④ 部员总时长排行</h3>
|
||||
<MiniTable heads={['排名', '班级', '姓名', '工作组', '活动数', '累计时长(h)']} rows={preview.user_totals.map((u, i) => [i + 1, u.class, u.name, u.group_name || '—', u.activity_count, Number(u.total_hours || 0).toFixed(2)])} />
|
||||
</section>
|
||||
)}
|
||||
{preview.user_activities?.length && (
|
||||
<section>
|
||||
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">⑤ 部员参加的活动</h3>
|
||||
<div className="space-y-4">
|
||||
{preview.user_activities.map(u => (
|
||||
<div key={u.id} className="border-2 border-gray-200">
|
||||
<div className="bg-gray-50 px-3 py-2 border-b-2 border-gray-200 font-bold">
|
||||
{u.class} {u.name} {u.group_name ? `(${u.group_name})` : ''}
|
||||
<span className="text-gray-500 font-normal text-sm ml-3">共 {u.activities.length} 次活动</span>
|
||||
</div>
|
||||
{u.activities.length === 0
|
||||
? <div className="p-3 text-sm text-gray-500">未参与任何活动</div>
|
||||
: <MiniTable heads={['活动名称', '时间', '时长(h)', '备注']} rows={u.activities.map(a => [a.title, formatDateRange(a.start_time, a.end_time), Number(a.hours || 0).toFixed(2), a.remark || '—'])} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b-2 border-black">
|
||||
{heads.map((h, i) => <th key={i} className="px-3 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">{h}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, ri) => (
|
||||
<tr key={ri} className="border-b border-gray-200">
|
||||
{r.map((c, ci) => <td key={ci} className="px-3 py-1.5 border border-gray-200 whitespace-nowrap">{c}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import Select from '../../components/Select';
|
||||
import Input from '../../components/Input';
|
||||
import { activitiesApi, hoursApi, errMsg } from '../../api';
|
||||
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export';
|
||||
import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types';
|
||||
|
||||
export default function Hours() {
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | ''>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [result, setResult] = useState<HoursActivityResult | null>(null);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [activityInfo, setActivityInfo] = useState<ActivityItem | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activitiesApi.list().then(setActivities).catch(e => alert(errMsg(e)));
|
||||
}, []);
|
||||
|
||||
const loadParticipants = async (id: number) => {
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
setParticipants([]);
|
||||
setResult(null);
|
||||
try {
|
||||
const [p, r] = await Promise.all([activitiesApi.participants(id), hoursApi.byActivity(id)]);
|
||||
setActivityInfo(p.activity);
|
||||
const existingMap: Record<number, HoursRow> = {};
|
||||
for (const row of r.list) existingMap[row.user_id] = row;
|
||||
const merged: Participant[] = p.list.map(u => existingMap[u.id]
|
||||
? { ...u, hours: existingMap[u.id].hours, remark: existingMap[u.id].remark || '', hours_id: existingMap[u.id].id }
|
||||
: u);
|
||||
setParticipants(merged);
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
alert(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) loadParticipants(Number(selectedId));
|
||||
else { setParticipants([]); setActivityInfo(null); setResult(null); }
|
||||
}, [selectedId]);
|
||||
|
||||
const updateRow = (idx: number, field: 'hours' | 'remark', value: string) => {
|
||||
setParticipants(list => list.map((p, i) => {
|
||||
if (i !== idx) return p;
|
||||
return { ...p, [field]: field === 'hours' ? Number(value) || 0 : value };
|
||||
}));
|
||||
};
|
||||
|
||||
const fillAll = (val: string) => {
|
||||
const n = Number(val) || 0;
|
||||
setParticipants(list => list.map(p => ({ ...p, hours: n })));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const items = participants.map(p => ({ user_id: p.id, hours: Number(p.hours) || 0, remark: String(p.remark || '') }));
|
||||
await hoursApi.batch(Number(selectedId), items);
|
||||
setMsg('✓ 已保存');
|
||||
loadParticipants(Number(selectedId));
|
||||
} catch (e) {
|
||||
setMsg(errMsg(e, '保存失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const n = participants.filter(p => (p.hours || 0) > 0).length;
|
||||
const h = participants.reduce((s, p) => s + (Number(p.hours) || 0), 0);
|
||||
return { people: n, hours: h };
|
||||
}, [participants]);
|
||||
|
||||
const doExcelExport = () => {
|
||||
if (!result && !activityInfo) { alert('请先选择活动'); return; }
|
||||
const title = activityInfo?.title || '活动';
|
||||
const data = participants.filter(p => (p.hours || 0) > 0 || p.remark).map((p, i) => ({
|
||||
'序号': i + 1,
|
||||
'班级': p.class,
|
||||
'姓名': p.name,
|
||||
'义工号': p.volunteer_no || '—',
|
||||
'工作组': p.group_name || '—',
|
||||
'义工时长(小时)': Number(p.hours || 0).toFixed(2),
|
||||
'备注': p.remark || '—',
|
||||
}));
|
||||
const meta = [{ '活动名称': title, '活动时间': formatDateRange(activityInfo?.start_time || '', activityInfo?.end_time || ''), '登记人数': summary.people, '总时长(小时)': summary.hours.toFixed(2) }];
|
||||
exportToExcel([{ name: '汇总', data: meta }, { name: '时长明细', data }], `义工时长-${title}-${new Date().toISOString().slice(0,10)}.xlsx`);
|
||||
};
|
||||
|
||||
const doImageExport = async () => {
|
||||
if (!tableRef.current) return;
|
||||
const title = activityInfo?.title || '活动';
|
||||
await exportTableToImage(tableRef.current, `义工时长-${title}-${new Date().toISOString().slice(0,10)}.png`);
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1.5">选择活动</label>
|
||||
<Select value={String(selectedId)} onChange={(e) => setSelectedId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||
<option value="">-- 请选择活动 --</option>
|
||||
{activities.map(a => <option key={a.id} value={a.id}>{a.title} ({formatDateRange(a.start_time, a.end_time)})</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="secondary" onClick={() => { const v = prompt('统一设置时长(小时)', '2'); if (v !== null) fillAll(v); }} disabled={!selectedId}>批量填时长</Button>
|
||||
<Button variant="secondary" onClick={doExcelExport} disabled={!selectedId}>📊 导出 Excel</Button>
|
||||
<Button variant="secondary" onClick={doImageExport} disabled={!selectedId}>🖼 导出图片</Button>
|
||||
<Button onClick={save} disabled={!selectedId || saving}>{saving ? '保存中...' : '💾 保存登记'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{activityInfo && (
|
||||
<div className="mt-4 pt-4 border-t-2 border-gray-200 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div><div className="text-xs text-gray-500">活动名称</div><div className="font-bold truncate">{activityInfo.title}</div></div>
|
||||
<div><div className="text-xs text-gray-500">活动时间</div><div className="font-medium text-sm">{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div></div>
|
||||
<div><div className="text-xs text-gray-500">已登记/总人数</div><div className="font-bold">{summary.people} / {participants.length}</div></div>
|
||||
<div><div className="text-xs text-gray-500">总时长</div><div className="font-bold">{summary.hours.toFixed(2)} 小时</div></div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <div className="mt-3 text-sm">{msg.includes('✓') ? <span className="text-green-600 font-medium">{msg}</span> : <span className="text-red-600">{msg}</span>}</div>}
|
||||
</div>
|
||||
|
||||
{loading && <div className="p-10 text-center text-gray-500">加载中...</div>}
|
||||
|
||||
{!loading && selectedId && (
|
||||
<div ref={tableRef} className="bg-white p-2 md:p-4 border-2 border-gray-200">
|
||||
{activityInfo && (
|
||||
<div className="mb-3 pb-3 border-b-2 border-black hidden md:block">
|
||||
<div className="text-center font-black text-xl mb-1">义工时长登记表</div>
|
||||
<div className="text-center text-sm text-gray-700">活动:{activityInfo.title} | 时间:{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b-2 border-black">
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">序号</th>
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">班级</th>
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">姓名</th>
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">义工号</th>
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">工作组</th>
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap" style={{ minWidth: '110px' }}>时长(小时)</th>
|
||||
<th className="px-2 py-2 text-left font-bold border border-gray-200" style={{ minWidth: '200px' }}>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participants.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-3 py-10 text-center text-gray-500 border border-gray-200">
|
||||
该活动暂无部员可登记(若选了工作组,请确认该工作组下有部员)
|
||||
</td></tr>
|
||||
) : participants.map((p, idx) => (
|
||||
<tr key={p.id} className="border-b border-gray-200">
|
||||
<td className="px-2 py-2 border border-gray-200">{idx + 1}</td>
|
||||
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.class}</td>
|
||||
<td className="px-2 py-2 border border-gray-200 font-bold whitespace-nowrap">{p.name}</td>
|
||||
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.volunteer_no || '—'}</td>
|
||||
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.group_name || '—'}</td>
|
||||
<td className="px-2 py-1 border border-gray-200">
|
||||
<Input type="number" step="0.1" min="0" value={String(p.hours)} onChange={(e) => updateRow(idx, 'hours', e.target.value)} className="py-1" />
|
||||
</td>
|
||||
<td className="px-2 py-1 border border-gray-200">
|
||||
<Input value={p.remark} onChange={(e) => updateRow(idx, 'remark', e.target.value)} placeholder="工作内容/备注" className="py-1" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedId && !loading && (
|
||||
<div className="bg-white border-2 border-dashed border-gray-300 p-10 text-center text-gray-500">
|
||||
请先在上方选择一个活动开始登记
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NavLink, Outlet, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import Button from '../../components/Button';
|
||||
import Modal from '../../components/Modal';
|
||||
import Input from '../../components/Input';
|
||||
import { authApi, errMsg } from '../../api';
|
||||
|
||||
const menu = [
|
||||
{ path: '/admin/activities', label: '活动设置', icon: '📅' },
|
||||
{ path: '/admin/hours', label: '时长登记', icon: '⏱️' },
|
||||
{ path: '/admin/users', label: '用户管理', icon: '👥' },
|
||||
{ path: '/admin/export', label: '数据导出', icon: '📤' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [pwdOpen, setPwdOpen] = useState(false);
|
||||
const [oldPwd, setOldPwd] = useState('');
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [pwdErr, setPwdErr] = useState('');
|
||||
const username = localStorage.getItem('volunteer_admin_username') || '';
|
||||
const token = localStorage.getItem('volunteer_admin_token');
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setMenuOpen(false);
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, []);
|
||||
|
||||
if (!token) return <Navigate to="/admin/login" replace />;
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('volunteer_admin_token');
|
||||
localStorage.removeItem('volunteer_admin_username');
|
||||
navigate('/admin/login');
|
||||
};
|
||||
|
||||
const doChangePwd = async () => {
|
||||
if (!oldPwd || !newPwd) { setPwdErr('请填写完整'); return; }
|
||||
setPwdErr('');
|
||||
try {
|
||||
await authApi.changePassword(oldPwd, newPwd);
|
||||
alert('修改成功,请重新登录');
|
||||
setPwdOpen(false);
|
||||
setOldPwd(''); setNewPwd('');
|
||||
logout();
|
||||
} catch (e) {
|
||||
setPwdErr(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col md:flex-row bg-gray-50">
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-black text-white">
|
||||
<div className="font-bold">🕒 管理台</div>
|
||||
<button className="text-2xl" onClick={() => setMenuOpen(v => !v)}>{menuOpen ? '✕' : '☰'}</button>
|
||||
</header>
|
||||
|
||||
<aside className={`${menuOpen ? 'block' : 'hidden'} md:block w-full md:w-56 shrink-0 bg-white md:min-h-screen border-b-2 md:border-b-0 md:border-r-2 border-black`}>
|
||||
<div className="hidden md:block p-4 border-b-2 border-black bg-gray-50">
|
||||
<div className="font-black text-lg tracking-wide">🕒 义工管理</div>
|
||||
</div>
|
||||
<nav className="p-2 space-y-1">
|
||||
{menu.map((m) => (
|
||||
<NavLink
|
||||
key={m.path}
|
||||
to={m.path}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 text-sm font-medium border-2 ${isActive ? 'bg-black text-white border-black' : 'text-black border-transparent hover:bg-gray-100'}`
|
||||
}
|
||||
>
|
||||
<span className="text-lg">{m.icon}</span>{m.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="md:absolute md:bottom-0 md:left-0 md:w-56 p-3 border-t-2 border-gray-200 bg-white">
|
||||
<div className="text-xs text-gray-500 mb-2">当前管理员:<span className="font-bold text-black">{username}</span></div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button size="sm" variant="secondary" className="flex-1" onClick={() => setPwdOpen(true)}>改密码</Button>
|
||||
<Button size="sm" variant="danger" className="flex-1" onClick={logout}>退出</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<Modal open={pwdOpen} title="修改密码" onClose={() => setPwdOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPwdOpen(false)}>取消</Button>
|
||||
<Button onClick={doChangePwd}>确认修改</Button>
|
||||
</>
|
||||
}>
|
||||
<div className="space-y-4 max-w-sm">
|
||||
<div><label className="block text-sm font-bold mb-1.5">原密码</label>
|
||||
<Input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} />
|
||||
</div>
|
||||
<div><label className="block text-sm font-bold mb-1.5">新密码(至少 6 位)</label>
|
||||
<Input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
|
||||
</div>
|
||||
{pwdErr && <div className="text-sm text-red-600">{pwdErr}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Input from '../../components/Input';
|
||||
import Button from '../../components/Button';
|
||||
import { authApi, errMsg } from '../../api';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
setError('请输入用户名和密码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await authApi.login(username, password);
|
||||
localStorage.setItem('volunteer_admin_token', r.token);
|
||||
localStorage.setItem('volunteer_admin_username', r.username);
|
||||
navigate('/admin/activities');
|
||||
} catch (e) {
|
||||
setError(errMsg(e, '登录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<form onSubmit={submit} className="w-full max-w-md bg-white border-2 border-black p-6 md:p-8">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-4xl mb-2">🕒</div>
|
||||
<h1 className="text-2xl font-black tracking-wide">义工时长管理系统</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">管理台登录</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1.5">用户名</label>
|
||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold mb-1.5">密码</label>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
<Button type="submit" size="lg" className="w-full" disabled={loading}>
|
||||
{loading ? '登录中...' : '登 录'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
<p>默认账号:admin / admin123</p>
|
||||
<a href="/u" className="inline-block mt-2 underline underline-offset-2 hover:text-black">← 返回用户端</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Button from '../../components/Button';
|
||||
import DataTable from '../../components/DataTable';
|
||||
import { usersApi, errMsg } from '../../api';
|
||||
import { formatDateRange } from '../../utils/export';
|
||||
import type { UserDetail } from '../../types';
|
||||
|
||||
export default function UserDashboard() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<UserDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
usersApi.detail(Number(id)).then(d => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
}).catch(e => {
|
||||
setError(errMsg(e, '加载失败'));
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-gray-500">加载中...</div>;
|
||||
if (error) return <div className="p-8 text-center"><p className="text-red-600 mb-4">{error}</p><Button onClick={() => navigate('/u')}>返回查询</Button></div>;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 px-4 py-6 md:py-10">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-5 flex-wrap gap-2">
|
||||
<h1 className="text-xl md:text-2xl font-black tracking-wide">🕒 个人义工时长总览</h1>
|
||||
<Button variant="secondary" onClick={() => navigate('/u')}>← 返回查询</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-2 border-black p-5 mb-5">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">姓名</div>
|
||||
<div className="text-lg font-bold">{data.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">班级</div>
|
||||
<div className="text-lg font-bold">{data.class || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">义工号</div>
|
||||
<div className="text-lg font-bold">{data.volunteer_no || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 mb-1">所属工作组</div>
|
||||
<div className="text-lg font-bold">{data.group_name || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t-2 border-gray-200 grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 border-2 border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">累计义工时长</div>
|
||||
<div className="text-2xl md:text-3xl font-black text-black">{Number(data.total_hours || 0).toFixed(2)} <span className="text-base font-bold text-gray-600">小时</span></div>
|
||||
</div>
|
||||
<div className="bg-gray-50 border-2 border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500 mb-1">参与活动数</div>
|
||||
<div className="text-2xl md:text-3xl font-black text-black">{data.activities.length} <span className="text-base font-bold text-gray-600">次</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="font-bold text-lg mb-3">参与活动明细</h2>
|
||||
<DataTable
|
||||
id="user-activities-table"
|
||||
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)}</span> },
|
||||
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
|
||||
]}
|
||||
data={data.activities}
|
||||
rowKey={(r: any) => r.id}
|
||||
emptyText="暂无参与的活动记录"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Input from '../../components/Input';
|
||||
import Button from '../../components/Button';
|
||||
import DataTable from '../../components/DataTable';
|
||||
import { usersApi, errMsg } from '../../api';
|
||||
import type { UserItem } from '../../types';
|
||||
|
||||
export default function UserIndex() {
|
||||
const [name, setName] = useState('');
|
||||
const [results, setResults] = useState<UserItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const doSearch = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const list = await usersApi.search(name.trim());
|
||||
setResults(list);
|
||||
} catch (e) {
|
||||
setError(errMsg(e, '查询失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center bg-gray-50 px-4 py-8 md:py-16">
|
||||
<div className="w-full max-w-xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="text-4xl md:text-5xl mb-3">🕒</div>
|
||||
<h1 className="text-2xl md:text-3xl font-black text-black tracking-wide">义工时长查询</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm md:text-base">请输入您的姓名查询个人义工时长</p>
|
||||
<div className="mt-4 flex justify-center gap-4 text-sm">
|
||||
<a href="/admin/login" className="text-gray-500 hover:text-black underline underline-offset-2">管理入口</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={doSearch} className="bg-white border-2 border-black p-4 md:p-6">
|
||||
<label className="block text-sm font-bold mb-2">姓名</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="请输入姓名(支持模糊搜索)"
|
||||
className="flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" disabled={loading}>{loading ? '查询中' : '查询'}</Button>
|
||||
</div>
|
||||
{error && <div className="mt-3 text-sm text-red-600">{error}</div>}
|
||||
</form>
|
||||
|
||||
{results !== null && (
|
||||
<div className="mt-6">
|
||||
<h2 className="font-bold mb-2 text-black">
|
||||
查询结果:{results.length} 人
|
||||
{results.length === 0 && ' (未找到匹配的部员,请确认姓名是否正确)'}
|
||||
</h2>
|
||||
{results.length === 1 ? (
|
||||
<div className="bg-white border-2 border-black p-4">
|
||||
<div className="flex justify-between items-center flex-wrap gap-3">
|
||||
<div>
|
||||
<div className="font-bold text-lg">{results[0].name}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
班级:{results[0].class || '—'} 义工号:{results[0].volunteer_no || '—'}
|
||||
</div>
|
||||
{results[0].group_name && <div className="text-sm text-gray-600 mt-0.5">工作组:{results[0].group_name}</div>}
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/u/${results[0].id}`)}>查看详情</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : results.length > 1 ? (
|
||||
<DataTable<UserItem>
|
||||
columns={[
|
||||
{ key: 'name', title: '姓名', render: (r) => <span className="font-bold">{r.name}</span> },
|
||||
{ key: 'class', title: '班级' },
|
||||
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
||||
{ key: 'group_name', title: '工作组', render: (r) => r.group_name || '—' },
|
||||
{
|
||||
key: 'op', title: '操作', render: (r) => (
|
||||
<Button size="sm" onClick={() => navigate(`/u/${r.id}`)}>进入</Button>
|
||||
)
|
||||
}
|
||||
]}
|
||||
data={results}
|
||||
rowKey={(r) => r.id}
|
||||
emptyText=""
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user