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,28 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import UserIndex from './pages/user/Index';
|
||||
import UserDashboard from './pages/user/Dashboard';
|
||||
import AdminLogin from './pages/admin/Login';
|
||||
import AdminLayout from './pages/admin/Layout';
|
||||
import Activities from './pages/admin/Activities';
|
||||
import Hours from './pages/admin/Hours';
|
||||
import Users from './pages/admin/Users';
|
||||
import Export from './pages/admin/Export';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/u" replace />} />
|
||||
<Route path="/u" element={<UserIndex />} />
|
||||
<Route path="/u/:id" element={<UserDashboard />} />
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="activities" replace />} />
|
||||
<Route path="activities" element={<Activities />} />
|
||||
<Route path="hours" element={<Hours />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="export" element={<Export />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/u" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
WorkGroup, UserItem, UserDetail, ActivityItem, Participant,
|
||||
HoursActivityResult, ExportResult,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('volunteer_admin_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('volunteer_admin_token');
|
||||
localStorage.removeItem('volunteer_admin_username');
|
||||
if (!window.location.pathname.startsWith('/admin/login')) {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export const errMsg = (e: any, def = '操作失败'): string => {
|
||||
return e?.response?.data?.error || e?.message || def;
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ token: string; username: string }>('/auth/login', { username, password }).then(r => r.data),
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
api.post('/auth/change-password', { oldPassword, newPassword }).then(r => r.data),
|
||||
};
|
||||
|
||||
export const groupsApi = {
|
||||
list: () => api.get<WorkGroup[]>('/groups').then(r => r.data),
|
||||
create: (name: string) => api.post<WorkGroup>('/groups', { name }).then(r => r.data),
|
||||
update: (id: number, name: string) => api.put(`/groups/${id}`, { name }).then(r => r.data),
|
||||
remove: (id: number) => api.delete(`/groups/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
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),
|
||||
remove: (id: number) => api.delete(`/users/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const activitiesApi = {
|
||||
list: () => api.get<ActivityItem[]>('/activities').then(r => r.data),
|
||||
detail: (id: number) => api.get<ActivityItem>(`/activities/${id}`).then(r => r.data),
|
||||
create: (data: any) => api.post('/activities', data).then(r => r.data),
|
||||
update: (id: number, data: any) => api.put(`/activities/${id}`, data).then(r => r.data),
|
||||
remove: (id: number) => api.delete(`/activities/${id}`).then(r => r.data),
|
||||
participants: (id: number) => api.get<{ activity: ActivityItem; list: Participant[] }>(`/activities/${id}/participants`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const hoursApi = {
|
||||
batch: (activity_id: number, items: Array<{ user_id: number; hours: number; remark: string }>) =>
|
||||
api.post('/hours/batch', { activity_id, items }).then(r => r.data),
|
||||
byActivity: (id: number) => api.get<HoursActivityResult>(`/hours/activity/${id}`).then(r => r.data),
|
||||
};
|
||||
|
||||
export const exportApi = {
|
||||
data: (opts: {
|
||||
include_users?: boolean;
|
||||
include_activities?: boolean;
|
||||
include_hours?: boolean;
|
||||
include_user_totals?: boolean;
|
||||
include_user_activities?: boolean;
|
||||
}) => api.post<ExportResult>('/export', opts).then(r => r.data),
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Button({ variant = 'primary', size = 'md', className = '', children, ...rest }: Props) {
|
||||
const base = 'inline-flex items-center justify-center font-medium border-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const sizeCls = size === 'sm' ? 'px-3 py-1.5 text-sm' : size === 'lg' ? 'px-6 py-3 text-lg' : 'px-4 py-2 text-base';
|
||||
const variantCls = {
|
||||
primary: 'bg-black text-white border-black hover:bg-gray-800 hover:border-gray-800',
|
||||
secondary: 'bg-white text-black border-gray-300 hover:bg-gray-100 hover:border-gray-400',
|
||||
danger: 'bg-red-500 text-white border-red-500 hover:bg-red-600 hover:border-red-600',
|
||||
ghost: 'bg-transparent text-black border-transparent hover:bg-gray-100',
|
||||
}[variant];
|
||||
return (
|
||||
<button {...rest} className={`${base} ${sizeCls} ${variantCls} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
title: string;
|
||||
width?: string;
|
||||
render?: (row: T, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
emptyText?: string;
|
||||
rowKey?: (row: T, index: number) => string | number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export default function DataTable<T>({ columns, data, emptyText = '暂无数据', rowKey, className = '', id }: Props<T>) {
|
||||
return (
|
||||
<div className={`w-full overflow-x-auto border-2 border-gray-200 bg-white ${className}`}>
|
||||
<table id={id} className="w-full text-sm md:text-base">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b-2 border-black">
|
||||
{columns.map((c) => (
|
||||
<th key={c.key} style={c.width ? { width: c.width } : undefined} className="px-3 py-2.5 text-left font-bold text-black whitespace-nowrap">
|
||||
{c.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-10 text-center text-gray-500">{emptyText}</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, idx) => (
|
||||
<tr key={rowKey ? rowKey(row, idx) : idx} className="border-b border-gray-200 hover:bg-gray-50">
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className="px-3 py-2.5 align-top">
|
||||
{c.render ? c.render(row, idx) : (row as any)[c.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { InputHTMLAttributes } from 'react';
|
||||
|
||||
export default function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
const { className = '', ...rest } = props;
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
className={`w-full px-3 py-2 border-2 border-gray-300 bg-white text-black text-sm md:text-base transition-colors ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import Button from './Button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export default function Modal({ open, title, children, onClose, footer, width = 'max-w-2xl' }: Props) {
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = 'hidden';
|
||||
else document.body.style.overflow = '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
|
||||
<div className={`bg-white w-full ${width} max-h-[90vh] flex flex-col border-2 border-black`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b-2 border-black bg-gray-50">
|
||||
<h3 className="font-bold text-lg">{title}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>✕</Button>
|
||||
</div>
|
||||
<div className="flex-1 p-4 overflow-auto">{children}</div>
|
||||
{footer && <div className="px-4 py-3 border-t-2 border-gray-200 bg-gray-50 flex justify-end gap-2 flex-wrap">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { SelectHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface Props extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Select({ className = '', children, ...rest }: Props) {
|
||||
return (
|
||||
<select {...rest} className={`w-full px-3 py-2 border-2 border-gray-300 bg-white text-sm md:text-base transition-colors ${className}`}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TextareaHTMLAttributes } from 'react';
|
||||
|
||||
export default function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className = '', ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={`w-full px-3 py-2 border-2 border-gray-300 bg-white text-sm md:text-base transition-colors ${className}`} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
background: #FAFAFA;
|
||||
color: #171717;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: #2563EB !important;
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface WorkGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
class: string;
|
||||
name: string;
|
||||
volunteer_no: string | null;
|
||||
group_id: number | null;
|
||||
group_name?: string | null;
|
||||
total_hours?: number;
|
||||
}
|
||||
|
||||
export interface UserDetail extends UserItem {
|
||||
activities: ActivityHours[];
|
||||
}
|
||||
|
||||
export interface ActivityHours {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
hours: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
created_at: string;
|
||||
groups: number[];
|
||||
groups_name_list: string[];
|
||||
groups_name?: string;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: number;
|
||||
user_id?: number;
|
||||
class: string;
|
||||
name: string;
|
||||
volunteer_no: string | null;
|
||||
group_name: string | null;
|
||||
hours: number;
|
||||
remark: string;
|
||||
hours_id: number | null;
|
||||
}
|
||||
|
||||
export interface HoursRow {
|
||||
id: number;
|
||||
hours: number;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
user_id: number;
|
||||
class: string;
|
||||
name: string;
|
||||
volunteer_no: string | null;
|
||||
group_name: string | null;
|
||||
}
|
||||
|
||||
export interface HoursActivityResult {
|
||||
activity: ActivityItem;
|
||||
list: HoursRow[];
|
||||
total_hours: number;
|
||||
total_people: number;
|
||||
}
|
||||
|
||||
export interface UserTotal {
|
||||
id: number;
|
||||
class: string;
|
||||
name: string;
|
||||
volunteer_no: string | null;
|
||||
group_name: string | null;
|
||||
total_hours: number;
|
||||
activity_count: number;
|
||||
}
|
||||
|
||||
export interface UserActivities {
|
||||
id: number;
|
||||
class: string;
|
||||
name: string;
|
||||
volunteer_no: string | null;
|
||||
group_name: string | null;
|
||||
activities: ActivityHours[];
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
users?: UserItem[];
|
||||
activities?: ActivityItem[];
|
||||
hours?: any[];
|
||||
user_totals?: UserTotal[];
|
||||
user_activities?: UserActivities[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function exportToExcel(sheets: Array<{ name: string; data: any[] }>, filename: string) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
for (const s of sheets) {
|
||||
const ws = XLSX.utils.json_to_sheet(s.data || []);
|
||||
const colWidths = Object.keys(s.data[0] || {}).map(k => ({ wch: Math.max(8, String(k).length + 2) }));
|
||||
ws['!cols'] = colWidths;
|
||||
XLSX.utils.book_append_sheet(wb, ws, s.name.slice(0, 31));
|
||||
}
|
||||
const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||
downloadBlob(new Blob([buf], { type: 'application/octet-stream' }), filename);
|
||||
}
|
||||
|
||||
export async function exportTableToImage(el: HTMLElement, filename: string) {
|
||||
const canvas = await html2canvas(el, {
|
||||
backgroundColor: '#FFFFFF',
|
||||
useCORS: true,
|
||||
scale: 2,
|
||||
logging: false,
|
||||
});
|
||||
return new Promise<void>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) downloadBlob(blob, filename);
|
||||
resolve();
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateRange(s: string, e: string) {
|
||||
const a = s?.replace('T', ' ')?.slice(0, 16);
|
||||
const b = e?.replace('T', ' ')?.slice(0, 16);
|
||||
return `${a || ''} ~ ${b || ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user