feat: 初始化义工时长管理系统

- 管理台:活动设置、时长登记、用户管理、数据导出(Excel/PNG)
- 用户端:姓名查询个人义工时长与活动记录
- 技术栈:React + Vite + TailwindCSS / Node.js + Express + sql.js (SQLite)
- 支持 Docker 部署,附带 Gitea Actions 自动部署工作流
- 响应式界面,兼容移动端与 PC 端
- 默认管理员账号:admin / admin123
This commit is contained in:
Trae Assistant
2026-10-04 18:06:24 +08:00
commit 25b2b8f381
50 changed files with 7918 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🕒</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>义工时长管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3171
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "volunteer-client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"dayjs": "^1.11.13",
"html2canvas": "^1.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+28
View File
@@ -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>
);
}
+86
View File
@@ -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),
};
+23
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+11
View File
@@ -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}`}
/>
);
}
+32
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+8
View File
@@ -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}`} />
);
}
+33
View File
@@ -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);
}
+13
View File
@@ -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>
);
+187
View File
@@ -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>
);
}
+174
View File
@@ -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>
);
}
+197
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+100
View File
@@ -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 || '—'} &nbsp; 义工号:{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>
);
}
+97
View File
@@ -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[];
}
+44
View File
@@ -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 || ''}`;
}
+52
View File
@@ -0,0 +1,52 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
borderRadius: {
DEFAULT: '0px',
none: '0px',
sm: '0px',
md: '0px',
lg: '0px',
xl: '0px',
'2xl': '0px',
full: '9999px',
},
colors: {
black: '#111111',
white: '#FFFFFF',
gray: {
50: '#FAFAFA',
100: '#F5F5F5',
200: '#E5E5E5',
300: '#D4D4D4',
400: '#A3A3A3',
500: '#737373',
600: '#525252',
700: '#404040',
800: '#262626',
900: '#171717',
},
blue: {
50: '#EFF6FF',
100: '#DBEAFE',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
},
red: {
500: '#EF4444',
600: '#DC2626',
},
green: {
500: '#22C55E',
600: '#16A34A',
},
},
extend: {},
},
plugins: [],
};
export default config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/index.ts","./src/components/button.tsx","./src/components/datatable.tsx","./src/components/input.tsx","./src/components/modal.tsx","./src/components/select.tsx","./src/components/textarea.tsx","./src/pages/admin/activities.tsx","./src/pages/admin/export.tsx","./src/pages/admin/hours.tsx","./src/pages/admin/layout.tsx","./src/pages/admin/login.tsx","./src/pages/admin/users.tsx","./src/pages/user/dashboard.tsx","./src/pages/user/index.tsx","./src/types/index.ts","./src/utils/export.ts"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});