feat(ui): SVG图标全替换 + Neo-Brutalist主题升级 + 数据导出卡片类别勾选
Deploy to Server / deploy (push) Failing after 14m55s

This commit is contained in:
Trae Assistant
2026-10-04 22:12:17 +08:00
parent 0169f19cf8
commit 9c7d6c1455
18 changed files with 657 additions and 178 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <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>" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='square' stroke-linejoin='miter'><rect x='3' y='4' width='18' height='16' rx='1'/><path d='M8 2v4M16 2v4M3 10h18M12 13v4M10 15h4'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>义工时长管理系统</title> <title>义工时长管理系统</title>
</head> </head>
+19 -7
View File
@@ -1,19 +1,31 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'; import { ButtonHTMLAttributes, ReactNode } from 'react';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'amber';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
children?: ReactNode; children?: ReactNode;
} }
export default function Button({ variant = 'primary', size = 'md', className = '', children, ...rest }: Props) { 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 base =
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'; 'inline-flex items-center justify-center font-bold border-2 border-black transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed active:translate-x-[2px] active:translate-y-[2px] active:shadow-none';
const sizeCls =
size === 'sm'
? 'px-3 py-1.5 text-sm shadow-sm'
: size === 'lg'
? 'px-6 py-3 text-lg shadow'
: 'px-4 py-2 text-base shadow';
const variantCls = { const variantCls = {
primary: 'bg-black text-white border-black hover:bg-gray-800 hover:border-gray-800', primary:
secondary: 'bg-white text-black border-gray-300 hover:bg-gray-100 hover:border-gray-400', 'bg-blue-600 text-white hover:bg-blue-700 hover:border-blue-800 shadow',
danger: 'bg-red-500 text-white border-red-500 hover:bg-red-600 hover:border-red-600', secondary:
ghost: 'bg-transparent text-black border-transparent hover:bg-gray-100', 'bg-white text-black hover:bg-gray-50 hover:border-gray-700 shadow-sm',
danger:
'bg-red-500 text-white hover:bg-red-600 hover:border-red-700 shadow',
ghost:
'bg-transparent text-black border-transparent hover:bg-gray-100 shadow-none active:translate-x-0 active:translate-y-0',
amber:
'bg-amber-400 text-black hover:bg-amber-500 shadow-amber',
}[variant]; }[variant];
return ( return (
<button {...rest} className={`${base} ${sizeCls} ${variantCls} ${className}`}> <button {...rest} className={`${base} ${sizeCls} ${variantCls} ${className}`}>
+9 -6
View File
@@ -18,12 +18,12 @@ interface Props<T> {
export default function DataTable<T>({ columns, data, emptyText = '暂无数据', rowKey, className = '', id }: Props<T>) { export default function DataTable<T>({ columns, data, emptyText = '暂无数据', rowKey, className = '', id }: Props<T>) {
return ( return (
<div className={`w-full overflow-x-auto border-2 border-gray-200 bg-white ${className}`}> <div className={`w-full overflow-x-auto border-2 border-black bg-white shadow-sm ${className}`}>
<table id={id} className="w-full text-sm md:text-base"> <table id={id} className="w-full text-sm md:text-base">
<thead> <thead>
<tr className="bg-gray-100 border-b-2 border-black"> <tr className="bg-black text-white border-b-2 border-black">
{columns.map((c) => ( {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"> <th key={c.key} style={c.width ? { width: c.width } : undefined} className="px-3 py-3 text-left font-black whitespace-nowrap">
{c.title} {c.title}
</th> </th>
))} ))}
@@ -32,13 +32,16 @@ export default function DataTable<T>({ columns, data, emptyText = '暂无数据'
<tbody> <tbody>
{data.length === 0 ? ( {data.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length} className="px-3 py-10 text-center text-gray-500">{emptyText}</td> <td colSpan={columns.length} className="px-3 py-12 text-center text-gray-500">{emptyText}</td>
</tr> </tr>
) : ( ) : (
data.map((row, idx) => ( data.map((row, idx) => (
<tr key={rowKey ? rowKey(row, idx) : idx} className="border-b border-gray-200 hover:bg-gray-50"> <tr
key={rowKey ? rowKey(row, idx) : idx}
className={`border-b border-gray-200 hover:bg-amber-50 transition-colors ${idx % 2 ? 'bg-gray-50/60' : 'bg-white'}`}
>
{columns.map((c) => ( {columns.map((c) => (
<td key={c.key} className="px-3 py-2.5 align-top"> <td key={c.key} className="px-3 py-3 align-top">
{c.render ? c.render(row, idx) : (row as any)[c.key]} {c.render ? c.render(row, idx) : (row as any)[c.key]}
</td> </td>
))} ))}
+124
View File
@@ -0,0 +1,124 @@
import React from 'react';
type IconProps = React.SVGProps<SVGSVGElement> & { size?: number };
const base = (size = 20): React.SVGProps<SVGSVGElement> => ({
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2.2,
strokeLinecap: 'square',
strokeLinejoin: 'miter',
});
export const IconClock: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
export const IconCalendar: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<rect x="3" y="5" width="18" height="16" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
export const IconTimer: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<circle cx="12" cy="13" r="7" />
<path d="M9 2h6M12 9v4l2.5 2" />
</svg>
);
export const IconUsers: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<circle cx="9" cy="8" r="3.2" />
<path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" />
<circle cx="17" cy="9" r="2.6" />
<path d="M15 20c0-2.4 2-4.4 4.5-4.4s2.6 1.3 2.6 3.6" />
</svg>
);
export const IconExport: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M12 3v10M7 8l5-5 5 5" />
<path d="M5 13v6h14v-6" />
</svg>
);
export const IconPlus: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const IconClose: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
export const IconMenu: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
);
export const IconLogo: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M8 2v4M16 2v4M3 10h18" />
<path d="M12 13v4M10 15h4" />
</svg>
);
export const IconArrowLeft: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M19 12H5M11 18l-6-6 6-6" />
</svg>
);
export const IconSearch: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
export const IconTrash: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
</svg>
);
export const IconEdit: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M4 20h4L20 8l-4-4L4 16v4zM14 6l4 4" />
</svg>
);
export const IconEye: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
export const IconFileExcel: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M14 3H6v18h12V7l-4-4z" />
<path d="M14 3v4h4M9 12l6 6M15 12l-6 6" />
</svg>
);
export const IconFileImage: React.FC<IconProps> = ({ size, ...rest }) => (
<svg {...base(size)} {...rest}>
<path d="M14 3H6v18h12V7l-4-4z" />
<path d="M14 3v4h4M9 15l2-2 2 2 3-3 3 3" />
<circle cx="9.5" cy="11.5" r="1.2" />
</svg>
);
+1 -1
View File
@@ -5,7 +5,7 @@ export default function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return ( return (
<input <input
{...rest} {...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}`} className={`w-full px-3 py-2.5 border-2 border-black bg-white text-black text-sm md:text-base transition-all focus:bg-blue-50 focus:border-blue-700 placeholder:text-gray-400 ${className}`}
/> />
); );
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { ReactNode, useEffect } from 'react'; import { ReactNode, useEffect } from 'react';
import Button from './Button'; import Button from './Button';
import { IconClose } from './Icons';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -22,7 +23,7 @@ export default function Modal({ open, title, children, onClose, footer, width =
<div className={`bg-white w-full ${width} max-h-[90vh] flex flex-col border-2 border-black`} onClick={(e) => e.stopPropagation()}> <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"> <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> <h3 className="font-bold text-lg">{title}</h3>
<Button variant="ghost" size="sm" onClick={onClose}>✕</Button> <Button variant="ghost" size="sm" onClick={onClose} aria-label="关闭"><IconClose size={18} /></Button>
</div> </div>
<div className="flex-1 p-4 overflow-auto">{children}</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>} {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>}
+1 -1
View File
@@ -6,7 +6,7 @@ interface Props extends SelectHTMLAttributes<HTMLSelectElement> {
export default function Select({ className = '', children, ...rest }: Props) { export default function Select({ className = '', children, ...rest }: Props) {
return ( 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}`}> <select {...rest} className={`w-full px-3 py-2.5 border-2 border-black bg-white text-sm md:text-base transition-all focus:bg-blue-50 focus:border-blue-700 appearance-none bg-no-repeat bg-[right_0.75rem_center] pr-9 ${className}`}>
{children} {children}
</select> </select>
); );
+1 -1
View File
@@ -3,6 +3,6 @@ import { TextareaHTMLAttributes } from 'react';
export default function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) { export default function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
const { className = '', ...rest } = props; const { className = '', ...rest } = props;
return ( 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}`} /> <textarea {...rest} className={`w-full px-3 py-2.5 border-2 border-black bg-white text-sm md:text-base transition-all focus:bg-blue-50 focus:border-blue-700 placeholder:text-gray-400 ${className}`} />
); );
} }
+52 -15
View File
@@ -2,32 +2,69 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
* { @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Noto+Sans+SC:wght@400;500;700;900&family=JetBrains+Mono:wght@500;700&display=swap');
box-sizing: border-box;
:root {
color-scheme: light;
} }
html, body, #root { html, body, #root {
height: 100%; 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 { body {
border-collapse: collapse; font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background-color: #fafaf6;
background-image:
radial-gradient(circle at 1px 1px, rgba(15, 23, 42, 0.06) 1px, transparent 0);
background-size: 22px 22px;
color: #0f172a;
} }
button { .font-display {
cursor: pointer; font-family: 'Instrument Serif', 'Noto Serif SC', 'Source Han Serif', 'Songti SC', SimSun, serif;
letter-spacing: 0.01em;
} }
input, select, textarea { *:focus-visible {
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
border-color: #2563EB;
} }
input:focus, select:focus, textarea:focus { * {
border-color: #2563EB !important; -webkit-tap-highlight-color: transparent;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); }
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-left: 1px solid #cbd5e1;
}
::-webkit-scrollbar-thumb {
background: #0f172a;
border: 2px solid #f1f5f9;
}
::-webkit-scrollbar-thumb:hover {
background: #1d4ed8;
}
@layer components {
.card {
@apply bg-white border-2 border-black shadow-sm;
}
.surface {
@apply bg-paper border-2 border-black;
}
.chip {
@apply inline-flex items-center px-2.5 py-1 text-xs font-bold border-2 border-black bg-white;
}
.paper {
@apply bg-white border-2 border-black shadow;
}
} }
+6 -2
View File
@@ -8,6 +8,7 @@ import Textarea from '../../components/Textarea';
import { activitiesApi, groupsApi, errMsg } from '../../api'; import { activitiesApi, groupsApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export'; import { formatDateRange } from '../../utils/export';
import type { ActivityItem, WorkGroup } from '../../types'; import type { ActivityItem, WorkGroup } from '../../types';
import { IconCalendar, IconPlus, IconTrash, IconEdit } from '../../components/Icons';
type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] }; type Form = { title: string; content: string; start_time: string; end_time: string; groups: number[] };
const emptyForm: Form = { title: '', content: '', start_time: '', end_time: '', groups: [] }; const emptyForm: Form = { title: '', content: '', start_time: '', end_time: '', groups: [] };
@@ -99,13 +100,16 @@ export default function Activities() {
return ( return (
<div> <div>
<div className="flex justify-between items-start flex-wrap gap-3 mb-5"> <div className="flex justify-between items-start flex-wrap gap-3 mb-5">
<div className="flex items-start gap-2">
<IconCalendar size={28} className="mt-0.5 shrink-0" />
<div> <div>
<h1 className="text-xl md:text-2xl font-black mb-1">📅 活动设置</h1> <h1 className="text-xl md:text-2xl font-black mb-1">活动设置</h1>
<p className="text-sm text-gray-500">发布、管理活动,并设置参与的工作组</p> <p className="text-sm text-gray-500">发布、管理活动,并设置参与的工作组</p>
</div> </div>
</div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button variant="secondary" onClick={() => setGroupsOpen(true)}>管理工作组</Button> <Button variant="secondary" onClick={() => setGroupsOpen(true)}>管理工作组</Button>
<Button onClick={openNew}>+ 新建活动</Button> <Button onClick={openNew}><span className="inline-flex items-center gap-2"><IconPlus size={18} />新建活动</span></Button>
</div> </div>
</div> </div>
+287 -71
View File
@@ -1,8 +1,18 @@
import { useRef, useState } from 'react'; import { ReactNode, useRef, useState } from 'react';
import Button from '../../components/Button'; import Button from '../../components/Button';
import { exportApi, errMsg } from '../../api'; import { exportApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export'; import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export';
import type { ExportResult } from '../../types'; import type { ExportResult } from '../../types';
import {
IconExport,
IconFileExcel,
IconFileImage,
IconUsers,
IconCalendar,
IconTimer,
IconClock,
IconEye,
} from '../../components/Icons';
type Opts = { type Opts = {
include_users: boolean; include_users: boolean;
@@ -20,6 +30,64 @@ const defaultOpts: Opts = {
include_user_activities: false, include_user_activities: false,
}; };
type Category = {
key: keyof Opts;
tag: string;
title: string;
subtitle: string;
desc: string;
icon: ReactNode;
accent: string;
};
const CATEGORIES: Category[] = [
{
key: 'include_users',
tag: '01',
title: '部员名册',
subtitle: 'Users',
desc: '导出所有部员的班级、姓名、义工号与所属工作组',
icon: <IconUsers size={32} />,
accent: 'from-blue-500 to-blue-700',
},
{
key: 'include_activities',
tag: '02',
title: '活动列表',
subtitle: 'Activities',
desc: '导出所有活动的标题、时间、参与工作组',
icon: <IconCalendar size={32} />,
accent: 'from-amber-500 to-amber-700',
},
{
key: 'include_hours',
tag: '03',
title: '义工时长明细',
subtitle: 'All Hours',
desc: '所有部员在所有活动中的时长登记记录(含备注)',
icon: <IconTimer size={32} />,
accent: 'from-black to-gray-800',
},
{
key: 'include_user_totals',
tag: '04',
title: '部员总时长',
subtitle: 'User Totals',
desc: '所有部员各自的累计总时长与活动数(按时长降序排名)',
icon: <IconClock size={32} />,
accent: 'from-green-500 to-green-700',
},
{
key: 'include_user_activities',
tag: '05',
title: '部员活动明细',
subtitle: 'User Activities',
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
icon: <IconEye size={32} />,
accent: 'from-red-500 to-red-700',
},
];
export default function Export() { export default function Export() {
const [opts, setOpts] = useState<Opts>(defaultOpts); const [opts, setOpts] = useState<Opts>(defaultOpts);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -28,30 +96,117 @@ export default function Export() {
const toggle = (k: keyof Opts) => setOpts(o => ({ ...o, [k]: !o[k] })); const toggle = (k: keyof Opts) => setOpts(o => ({ ...o, [k]: !o[k] }));
const anySelected = Object.values(opts).some(Boolean); const anySelected = Object.values(opts).some(Boolean);
const count = Object.values(opts).filter(Boolean).length;
const load = async () => { const load = async () => {
if (!anySelected) { alert('请至少选择一项导出内容'); return; } if (!anySelected) {
setLoading(true); setPreview(null); alert('请至少选择一项导出内容');
return;
}
setLoading(true);
setPreview(null);
(async () => {
try { try {
const d = await exportApi.data(opts); const d = await exportApi.data(opts);
setPreview(d); setPreview(d);
} catch (e) { } catch (e) {
alert(errMsg(e)); alert(errMsg(e));
} finally { setLoading(false); } } finally {
setLoading(false);
}
})();
}; };
const doExcel = () => { const doExcel = () => {
if (!preview) return alert('请先生成预览'); if (!preview) {
alert('请先生成预览');
return;
}
const sheets: Array<{ name: string; data: any[] }> = []; 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.users?.length) {
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) })) }); sheets.push({
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) })) }); name: '部员名册',
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) })) }); 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) { if (preview.user_activities?.length) {
const flat: any[] = []; const flat: any[] = [];
preview.user_activities.forEach(u => { preview.user_activities.forEach(u => {
if (u.activities.length === 0) flat.push({ '班级': u.class, '姓名': u.name, '义工号': u.volunteer_no || '—', '工作组': u.group_name || '—', '活动名称': '(未参与任何活动)', '时间': '', '时长(小时)': '', '备注': '' }); if (u.activities.length === 0) {
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 || '—' })); 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 }); sheets.push({ name: '部员活动明细', data: flat });
} }
@@ -63,89 +218,142 @@ export default function Export() {
await exportTableToImage(previewRef.current, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.png`); 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 }) => ( return (
<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="space-y-6">
<header className="mb-2">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<input type="checkbox" className="mt-1 w-4 h-4" checked={value} onChange={onChange} /> <div className="shrink-0 p-3 bg-black text-white shadow-lg shadow">
<IconExport size={32} />
</div>
<div className="flex-1"> <div className="flex-1">
<div className="font-bold text-black">{title}</div> <h1 className="text-2xl md:text-3xl font-black tracking-tight">数据导出中心</h1>
<div className="text-sm text-gray-600 mt-0.5">{desc}</div> <p className="text-sm text-gray-600 mt-1">
勾选需要的导出类别(已选 <span className="font-black text-blue-700">{count}</span> 项),
生成预览后一键导出为 Excel 表格或 PNG 图片
</p>
</div>
</div>
</header>
<section className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{CATEGORIES.map((c, idx) => {
const active = opts[c.key];
return (
<label
key={c.key}
className={`group relative block cursor-pointer transition-all duration-150 ${active ? '-translate-x-0.5 -translate-y-0.5' : 'hover:-translate-x-px hover:-translate-y-px'}`}
style={{ animationDelay: `${idx * 40}ms` }}
>
<input type="checkbox" className="sr-only" checked={active} onChange={() => toggle(c.key)} />
<div className={`h-full border-2 ${active ? 'border-black shadow-lg shadow bg-white' : 'border-black bg-white shadow-sm hover:shadow'}`}>
<div className="relative overflow-hidden">
<div className={`flex items-start gap-4 p-4 pb-3 bg-gradient-to-br ${active ? c.accent : 'from-gray-50 to-gray-100'}`}>
<div className={`shrink-0 p-3 border-2 ${active ? 'border-white text-white' : 'border-black bg-white text-black shadow-sm'}`}>
{c.icon}
</div>
<div className="flex-1 min-w-0">
<div className={`text-xs font-mono font-bold tracking-wider ${active ? 'text-white/85' : 'text-gray-500'}`}>- {c.tag} -</div>
<div className={`text-lg md:text-xl font-black tracking-tight leading-tight ${active ? 'text-white' : 'text-black'}`}>{c.title}</div>
<div className={`text-[11px] md:text-xs uppercase font-bold tracking-wider mt-0.5 ${active ? 'text-white/85' : 'text-gray-500'} font-display italic`}>
{c.subtitle}
</div>
</div>
<div className="ml-1 shrink-0">
<div className={`w-7 h-7 grid place-items-center border-2 transition-all ${active ? 'border-white text-black bg-white' : 'border-black text-white'}`}>
{active ? 'OK' : ''}
</div>
</div>
</div>
<div className="p-4 pt-3 bg-white">
<div className="text-sm text-gray-700 leading-relaxed">{c.desc}</div>
</div>
</div> </div>
</div> </div>
</label> </label>
); );
})}
</section>
return ( <section className="paper p-4 md:p-5 bg-white">
<div> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div className="mb-5"> <div className="text-sm text-gray-600">
<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>
<div className="flex gap-2 flex-wrap justify-end"> <div className="flex gap-2 flex-wrap justify-end">
<Button variant="secondary" onClick={load} disabled={loading || !anySelected}>{loading ? '生成中...' : '生成预览'}</Button> <Button variant="secondary" onClick={load} disabled={loading || !anySelected}>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>📊 导出 Excel</Button> {loading ? '生成中...' : <span className="inline-flex items-center gap-2">生成预览</span>}
<Button onClick={doImage} disabled={!preview}>🖼 导出为图片</Button> </Button>
<Button variant="secondary" onClick={doExcel} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span>
</Button>
<Button onClick={doImage} disabled={!preview}>
<span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出为图片</span>
</Button>
</div> </div>
</div> </div>
</section>
{preview && ( {preview && (
<div ref={previewRef} className="bg-white border-2 border-gray-200 p-3 md:p-5 space-y-6"> <div ref={previewRef} className="paper bg-white p-3 md:p-6 space-y-8">
<div className="border-b-2 border-black pb-3 text-center hidden md:block"> <div className="border-b-2 border-black pb-4 text-center">
<div className="text-xl font-black">义工数据汇总导出</div> <div className="font-display text-3xl md:text-4xl font-black tracking-tight italic">Volunteer Data Report</div>
<div className="text-sm text-gray-600 mt-1">生成时间:{new Date().toLocaleString('zh-CN')}</div> <div className="text-sm text-gray-600 mt-1">义工数据汇总导出 | 生成时间:{new Date().toLocaleString('zh-CN')}</div>
</div> </div>
{preview.users?.length && ( {preview.users?.length ? (
<section> <section className="animate-stagger-in">
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">① 部员名册({preview.users.length} 人)</h3> <h3 className="font-black text-xl mb-3 border-l-4 border-blue-600 pl-3 inline-flex items-center gap-2">
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '—', u.group_name || '—'])} /> <span className="chip">01</span> 部员名册({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> </section>
)} ) : null}
{preview.activities?.length && ( {preview.activities?.length ? (
<section> <section className="animate-stagger-in">
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">② 活动列表({preview.activities.length} 个)</h3> <h3 className="font-black text-xl mb-3 border-l-4 border-amber-600 pl-3 inline-flex items-center gap-2">
<span className="chip">02</span> 活动列表({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 || '全部'])} /> <MiniTable heads={['序号', '活动标题', '时间', '参与工作组']} rows={preview.activities.map((a, i) => [i + 1, a.title, formatDateRange(a.start_time, a.end_time), a.groups_name || '全部'])} />
</section> </section>
)} ) : null}
{preview.hours?.length && ( {preview.hours?.length ? (
<section> <section className="animate-stagger-in">
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">③ 全部时长明细({preview.hours.length} 条)</h3> <h3 className="font-black text-xl mb-3 border-l-4 border-black pl-3 inline-flex items-center gap-2">
<MiniTable heads={['班级', '姓名', '活动', '时长(h)', '备注']} rows={preview.hours.map((h: any) => [h.class, h.name, h.activity_title, Number(h.hours || 0).toFixed(2), h.remark || '—'])} /> <span className="chip">03</span> 全部时长明细({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> </section>
)} ) : null}
{preview.user_totals?.length && ( {preview.user_totals?.length ? (
<section> <section className="animate-stagger-in">
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">④ 部员总时长排行</h3> <h3 className="font-black text-xl mb-3 border-l-4 border-green-600 pl-3 inline-flex items-center gap-2">
<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)])} /> <span className="chip">04</span> 部员总时长排行
</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> </section>
)} ) : null}
{preview.user_activities?.length && ( {preview.user_activities?.length ? (
<section> <section className="animate-stagger-in">
<h3 className="font-black text-lg mb-2 border-l-4 border-black pl-3">⑤ 部员参加的活动</h3> <h3 className="font-black text-xl mb-3 border-l-4 border-red-600 pl-3 inline-flex items-center gap-2">
<span className="chip">05</span> 部员参加的活动
</h3>
<div className="space-y-4"> <div className="space-y-4">
{preview.user_activities.map(u => ( {preview.user_activities.map(u => (
<div key={u.id} className="border-2 border-gray-200"> <div key={u.id} className="border-2 border-black bg-gray-50 shadow-sm">
<div className="bg-gray-50 px-3 py-2 border-b-2 border-gray-200 font-bold"> <div className="bg-black text-white px-4 py-2 border-b-2 border-black font-black flex items-center justify-between flex-wrap gap-2">
{u.class} {u.name} {u.group_name ? `(${u.group_name})` : ''} <span className="font-black">{u.class} {u.name}{u.group_name ? ` (${u.group_name})` : ''}</span>
<span className="text-gray-500 font-normal text-sm ml-3">共 {u.activities.length} 次活动</span> <span className="text-xs font-bold text-gray-300 font-mono tracking-wider">共 {u.activities.length} 次活动</span>
</div> </div>
{u.activities.length === 0 {u.activities.length === 0 ? (
? <div className="p-3 text-sm text-gray-500">未参与任何活动</div> <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 || '—'])} />} ) : (
<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>
))} ))}
</div> </div>
</section> </section>
)} ) : null}
</div> </div>
)} )}
</div> </div>
@@ -154,17 +362,25 @@ export default function Export() {
function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) { function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
return ( return (
<div className="w-full overflow-x-auto"> <div className="w-full overflow-x-auto border-2 border-black bg-white">
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<thead> <thead>
<tr className="bg-gray-100 border-b-2 border-black"> <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>)} {heads.map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left font-black border border-gray-200 whitespace-nowrap bg-gray-900 text-white">
{h}
</th>
))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r, ri) => ( {rows.map((r, ri) => (
<tr key={ri} className="border-b border-gray-200"> <tr key={ri} className={ri % 2 ? 'bg-gray-50/70 border-b border-gray-200 hover:bg-amber-50 transition-colors' : 'bg-white border-b border-gray-200 hover:bg-amber-50 transition-colors'}>
{r.map((c, ci) => <td key={ci} className="px-3 py-1.5 border border-gray-200 whitespace-nowrap">{c}</td>)} {r.map((c, ci) => (
<td key={ci} className="px-3 py-2 border border-gray-200 whitespace-nowrap">
{c}
</td>
))}
</tr> </tr>
))} ))}
</tbody> </tbody>
+9 -5
View File
@@ -5,6 +5,7 @@ import Input from '../../components/Input';
import { activitiesApi, hoursApi, errMsg } from '../../api'; import { activitiesApi, hoursApi, errMsg } from '../../api';
import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export'; import { exportToExcel, exportTableToImage, formatDateRange } from '../../utils/export';
import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types'; import type { ActivityItem, HoursRow, Participant, HoursActivityResult } from '../../types';
import { IconClock, IconFileExcel, IconFileImage } from '../../components/Icons';
export default function Hours() { export default function Hours() {
const [activities, setActivities] = useState<ActivityItem[]>([]); const [activities, setActivities] = useState<ActivityItem[]>([]);
@@ -106,10 +107,13 @@ export default function Hours() {
return ( return (
<div> <div>
<div className="mb-5"> <div className="mb-5 flex items-start gap-2">
<h1 className="text-xl md:text-2xl font-black mb-1">⏱️ 义工时长登记</h1> <IconClock size={28} className="mt-0.5 shrink-0" />
<div>
<h1 className="text-xl md:text-2xl font-black mb-1">义工时长登记</h1>
<p className="text-sm text-gray-500">选择活动 → 填写每位部员的时长与备注 → 保存并导出</p> <p className="text-sm text-gray-500">选择活动 → 填写每位部员的时长与备注 → 保存并导出</p>
</div> </div>
</div>
<div className="bg-white border-2 border-black p-4 mb-4"> <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 className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
@@ -122,9 +126,9 @@ export default function Hours() {
</div> </div>
<div className="flex gap-2 flex-wrap"> <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={() => { 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={doExcelExport} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconFileExcel size={18} />导出 Excel</span></Button>
<Button variant="secondary" onClick={doImageExport} disabled={!selectedId}>🖼 导出图片</Button> <Button variant="secondary" onClick={doImageExport} disabled={!selectedId}><span className="inline-flex items-center gap-2"><IconFileImage size={18} />导出图片</span></Button>
<Button onClick={save} disabled={!selectedId || saving}>{saving ? '保存中...' : '💾 保存登记'}</Button> <Button onClick={save} disabled={!selectedId || saving}>{saving ? '保存中...' : '保存登记'}</Button>
</div> </div>
</div> </div>
{activityInfo && ( {activityInfo && (
+38 -21
View File
@@ -1,15 +1,18 @@
import { NavLink, Outlet, useNavigate, Navigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate, Navigate } from 'react-router-dom';
import { useState, useEffect } from 'react'; import { useState, useEffect, ReactNode } from 'react';
import Button from '../../components/Button'; import Button from '../../components/Button';
import Modal from '../../components/Modal'; import Modal from '../../components/Modal';
import Input from '../../components/Input'; import Input from '../../components/Input';
import { authApi, errMsg } from '../../api'; import { authApi, errMsg } from '../../api';
import { IconCalendar, IconClock, IconUsers, IconExport, IconLogo, IconClose, IconMenu } from '../../components/Icons';
const menu = [ type MenuItem = { path: string; label: string; icon: ReactNode };
{ path: '/admin/activities', label: '活动设置', icon: '📅' },
{ path: '/admin/hours', label: '时长登记', icon: '⏱️' }, const menu: MenuItem[] = [
{ path: '/admin/users', label: '用户管理', icon: '👥' }, { path: '/admin/activities', label: '活动设置', icon: <IconCalendar size={20} /> },
{ path: '/admin/export', label: '数据导出', icon: '📤' }, { path: '/admin/hours', label: '时长登记', icon: <IconClock size={20} /> },
{ path: '/admin/users', label: '用户管理', icon: <IconUsers size={20} /> },
{ path: '/admin/export', label: '数据导出', icon: <IconExport size={20} /> },
]; ];
export default function Layout() { export default function Layout() {
@@ -51,40 +54,54 @@ export default function Layout() {
}; };
return ( return (
<div className="min-h-screen flex flex-col md:flex-row bg-gray-50"> <div className="min-h-screen flex flex-col md:flex-row bg-paper">
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-black text-white"> <header className="md:hidden flex items-center justify-between px-4 py-3 bg-black text-white shadow">
<div className="font-bold">🕒 管理台</div> <div className="flex items-center gap-2 font-black text-lg">
<button className="text-2xl" onClick={() => setMenuOpen(v => !v)}>{menuOpen ? '✕' : '☰'}</button> <IconLogo size={22} />
<span className="tracking-wide">义工管理</span>
</div>
<button className="text-white" onClick={() => setMenuOpen(v => !v)} aria-label="菜单">
{menuOpen ? <IconClose size={22} /> : <IconMenu size={22} />}
</button>
</header> </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`}> <aside className={`${menuOpen ? 'block animate-slide-down' : 'hidden'} md:block w-full md:w-60 shrink-0 bg-gradient-to-b from-black via-black to-gray-900 text-white md:min-h-screen border-b-2 md:border-b-0 md:border-r-2 border-black md:sticky md:top-0 md:h-screen`}>
<div className="hidden md:block p-4 border-b-2 border-black bg-gray-50"> <div className="hidden md:block p-5 border-b-2 border-white/15 bg-black">
<div className="font-black text-lg tracking-wide">🕒 义工管理</div> <div className="flex items-center gap-2.5 font-black text-xl tracking-wide text-white">
<IconLogo size={24} />
<span className="font-display italic">Volunteer</span>
</div> </div>
<nav className="p-2 space-y-1"> <div className="mt-1 text-xs text-gray-400">义工时长管理系统</div>
</div>
<nav className="p-3 space-y-1.5">
{menu.map((m) => ( {menu.map((m) => (
<NavLink <NavLink
key={m.path} key={m.path}
to={m.path} to={m.path}
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
className={({ isActive }) => 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'}` `flex items-center gap-3 px-4 py-3 text-sm font-bold border-2 transition-all ${
isActive
? 'bg-blue-600 text-white border-blue-400 shadow-sm translate-x-[2px]'
: 'text-gray-100 border-transparent hover:bg-white/10 hover:border-white/20 hover:text-white'
}`
} }
> >
<span className="text-lg">{m.icon}</span>{m.label} <span className="inline-flex">{m.icon}</span>
<span>{m.label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </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="md:absolute md:bottom-0 md:left-0 md:w-60 p-3 border-t-2 border-white/15 bg-black/60 backdrop-blur">
<div className="text-xs text-gray-500 mb-2">当前管理员:<span className="font-bold text-black">{username}</span></div> <div className="text-xs text-gray-400 mb-2">当前管理员:<span className="font-bold text-white">{username}</span></div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<Button size="sm" variant="secondary" className="flex-1" onClick={() => setPwdOpen(true)}>改密码</Button> <Button size="sm" variant="ghost" className="flex-1 !text-gray-200 hover:!bg-white/10" onClick={() => setPwdOpen(true)}>改密码</Button>
<Button size="sm" variant="danger" className="flex-1" onClick={logout}>退出</Button> <Button size="sm" variant="danger" className="flex-1" onClick={logout}>退出</Button>
</div> </div>
</div> </div>
</aside> </aside>
<main className="flex-1 p-4 md:p-6 min-h-screen"> <main className="flex-1 p-4 md:p-8 min-h-screen max-w-[1400px] w-full">
<Outlet /> <Outlet />
</main> </main>
@@ -102,7 +119,7 @@ export default function Layout() {
<div><label className="block text-sm font-bold mb-1.5">新密码(至少 6 位)</label> <div><label className="block text-sm font-bold mb-1.5">新密码(至少 6 位)</label>
<Input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} /> <Input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} />
</div> </div>
{pwdErr && <div className="text-sm text-red-600">{pwdErr}</div>} {pwdErr && <div className="text-sm text-red-600 font-bold">{pwdErr}</div>}
</div> </div>
</Modal> </Modal>
</div> </div>
+3 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import Input from '../../components/Input'; import Input from '../../components/Input';
import Button from '../../components/Button'; import Button from '../../components/Button';
import { authApi, errMsg } from '../../api'; import { authApi, errMsg } from '../../api';
import { IconLogo } from '../../components/Icons';
export default function Login() { export default function Login() {
const [username, setUsername] = useState('admin'); const [username, setUsername] = useState('admin');
@@ -34,8 +35,8 @@ export default function Login() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <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"> <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-center mb-6 flex flex-col items-center">
<div className="text-4xl mb-2">🕒</div> <div className="mb-2 text-black"><IconLogo size={48} /></div>
<h1 className="text-2xl font-black tracking-wide">义工时长管理系统</h1> <h1 className="text-2xl font-black tracking-wide">义工时长管理系统</h1>
<p className="text-gray-500 text-sm mt-1">管理台登录</p> <p className="text-gray-500 text-sm mt-1">管理台登录</p>
</div> </div>
+10 -6
View File
@@ -7,6 +7,7 @@ import Modal from '../../components/Modal';
import { groupsApi, usersApi, errMsg } from '../../api'; import { groupsApi, usersApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export'; import { formatDateRange } from '../../utils/export';
import type { UserItem, UserDetail, WorkGroup } from '../../types'; import type { UserItem, UserDetail, WorkGroup } from '../../types';
import { IconUsers, IconPlus, IconEdit, IconTrash, IconEye } from '../../components/Icons';
type Form = { class: string; name: string; volunteer_no: string; group_id: number | null }; type Form = { class: string; name: string; volunteer_no: string; group_id: number | null };
const emptyForm: Form = { class: '', name: '', volunteer_no: '', group_id: null }; const emptyForm: Form = { class: '', name: '', volunteer_no: '', group_id: null };
@@ -67,14 +68,17 @@ export default function Users() {
return ( return (
<div> <div>
<div className="mb-5"> <div className="mb-5 flex items-start gap-2">
<h1 className="text-xl md:text-2xl font-black mb-1">👥 用户管理</h1> <IconUsers size={28} className="mt-0.5 shrink-0" />
<div>
<h1 className="text-xl md:text-2xl font-black mb-1">用户管理</h1>
<p className="text-sm text-gray-500">管理部员信息,查看个人累计义工时长与参与活动明细</p> <p className="text-sm text-gray-500">管理部员信息,查看个人累计义工时长与参与活动明细</p>
</div> </div>
</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"> <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" /> <Input placeholder="搜索 姓名/班级/义工号/工作组..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="md:max-w-md" />
<Button onClick={openNew}>+ 新增部员</Button> <Button onClick={openNew}><span className="inline-flex items-center gap-2"><IconPlus size={18} />新增部员</span></Button>
</div> </div>
<DataTable<UserItem> <DataTable<UserItem>
@@ -87,9 +91,9 @@ export default function Users() {
{ {
key: 'op', title: '操作', width: '220px', render: (r) => ( key: 'op', title: '操作', width: '220px', render: (r) => (
<div className="flex gap-1.5 flex-wrap"> <div className="flex gap-1.5 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => openDetail(r)}>详情</Button> <Button size="sm" variant="secondary" onClick={() => openDetail(r)}><span className="inline-flex items-center gap-1.5"><IconEye size={14} />详情</span></Button>
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}>编辑</Button> <Button size="sm" variant="secondary" onClick={() => openEdit(r)}><span className="inline-flex items-center gap-1.5"><IconEdit size={14} />编辑</span></Button>
<Button size="sm" variant="danger" onClick={() => onDelete(r)}>删除</Button> <Button size="sm" variant="danger" onClick={() => onDelete(r)}><span className="inline-flex items-center gap-1.5"><IconTrash size={14} />删除</span></Button>
</div> </div>
) )
} }
+6 -2
View File
@@ -5,6 +5,7 @@ import DataTable from '../../components/DataTable';
import { usersApi, errMsg } from '../../api'; import { usersApi, errMsg } from '../../api';
import { formatDateRange } from '../../utils/export'; import { formatDateRange } from '../../utils/export';
import type { UserDetail } from '../../types'; import type { UserDetail } from '../../types';
import { IconClock, IconArrowLeft } from '../../components/Icons';
export default function UserDashboard() { export default function UserDashboard() {
const { id } = useParams(); const { id } = useParams();
@@ -33,8 +34,11 @@ export default function UserDashboard() {
<div className="min-h-screen bg-gray-50 px-4 py-6 md:py-10"> <div className="min-h-screen bg-gray-50 px-4 py-6 md:py-10">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
<div className="flex justify-between items-center mb-5 flex-wrap gap-2"> <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> <div className="flex items-start gap-2">
<Button variant="secondary" onClick={() => navigate('/u')}>← 返回查询</Button> <IconClock size={28} className="mt-0.5 shrink-0" />
<h1 className="text-xl md:text-2xl font-black tracking-wide">个人义工时长总览</h1>
</div>
<Button variant="secondary" onClick={() => navigate('/u')}><span className="inline-flex items-center gap-2"><IconArrowLeft size={18} />返回查询</span></Button>
</div> </div>
<div className="bg-white border-2 border-black p-5 mb-5"> <div className="bg-white border-2 border-black p-5 mb-5">
+3 -2
View File
@@ -5,6 +5,7 @@ import Button from '../../components/Button';
import DataTable from '../../components/DataTable'; import DataTable from '../../components/DataTable';
import { usersApi, errMsg } from '../../api'; import { usersApi, errMsg } from '../../api';
import type { UserItem } from '../../types'; import type { UserItem } from '../../types';
import { IconLogo } from '../../components/Icons';
export default function UserIndex() { export default function UserIndex() {
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -31,8 +32,8 @@ export default function UserIndex() {
return ( return (
<div className="min-h-screen flex flex-col items-center bg-gray-50 px-4 py-8 md:py-16"> <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="w-full max-w-xl">
<div className="mb-8 text-center"> <div className="mb-8 text-center flex flex-col items-center">
<div className="text-4xl md:text-5xl mb-3">🕒</div> <div className="mb-3 text-black"><IconLogo size={64} /></div>
<h1 className="text-2xl md:text-3xl font-black text-black tracking-wide">义工时长查询</h1> <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> <p className="text-gray-500 mt-2 text-sm md:text-base">请输入您的姓名查询个人义工时长</p>
<div className="mt-4 flex justify-center gap-4 text-sm"> <div className="mt-4 flex justify-center gap-4 text-sm">
+66 -15
View File
@@ -13,38 +13,89 @@ const config: Config = {
'2xl': '0px', '2xl': '0px',
full: '9999px', full: '9999px',
}, },
boxShadow: {
DEFAULT: '4px 4px 0 0 #0f172a',
sm: '2px 2px 0 0 #0f172a',
lg: '6px 6px 0 0 #0f172a',
xl: '8px 8px 0 0 #0f172a',
none: '0 0 0 0 #000',
amber: '4px 4px 0 0 #b45309',
},
colors: { colors: {
black: '#111111', black: '#0f172a',
ink: '#020617',
white: '#FFFFFF', white: '#FFFFFF',
paper: '#fafaf6',
gray: { gray: {
50: '#FAFAFA', 50: '#F8FAFC',
100: '#F5F5F5', 100: '#F1F5F9',
200: '#E5E5E5', 200: '#E2E8F0',
300: '#D4D4D4', 300: '#CBD5E1',
400: '#A3A3A3', 400: '#94A3B8',
500: '#737373', 500: '#64748B',
600: '#525252', 600: '#475569',
700: '#404040', 700: '#334155',
800: '#262626', 800: '#1E293B',
900: '#171717', 900: '#0F172A',
}, },
blue: { blue: {
50: '#EFF6FF', 50: '#EFF6FF',
100: '#DBEAFE', 100: '#DBEAFE',
500: '#3B82F6', 200: '#BFDBFE',
600: '#2563EB', 400: '#60A5FA',
700: '#1D4ED8', 500: '#2563EB',
600: '#1D4ED8',
700: '#1E40AF',
800: '#1E3A8A',
},
amber: {
50: '#FFFBEB',
100: '#FEF3C7',
300: '#FCD34D',
400: '#FBBF24',
500: '#F59E0B',
600: '#D97706',
700: '#B45309',
}, },
red: { red: {
400: '#F87171',
500: '#EF4444', 500: '#EF4444',
600: '#DC2626', 600: '#DC2626',
700: '#B91C1C',
}, },
green: { green: {
400: '#4ADE80',
500: '#22C55E', 500: '#22C55E',
600: '#16A34A', 600: '#16A34A',
}, },
}, },
extend: {}, fontFamily: {
sans: [
'"Noto Sans SC"', '"PingFang SC"', '"Microsoft YaHei"',
'ui-sans-serif', 'system-ui', 'sans-serif',
],
display: [
'"Instrument Serif"', '"Noto Serif SC"', '"Source Han Serif"',
'"Songti SC"', 'SimSun', 'serif',
],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
extend: {
keyframes: {
'stagger-in': {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-down': {
'0%': { opacity: '0', transform: 'translateY(-16px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'stagger-in': 'stagger-in 0.3s ease-out both',
'slide-down': 'slide-down 0.25s ease-out both',
},
},
}, },
plugins: [], plugins: [],
}; };