- 全量替换游戏感Neo-Brutalist为政务专业风:圆角2-16px、薄灰边、柔阴影 - Tailwind主题:primary 10阶调色板 + Noto Sans SC主字体 + fade-in-up动画 - 全局CSS:浅灰F6F8F9背景 + 主色焦点环和滚动条 + card/chip工具类 - 品牌组件Brand.tsx:云图形截断防云盘旧字泄露 + 新标题组合 - 6基础组件 + 8页面:Layout深绿侧栏 Login卡片 Export去五色卡 Activities/Hours/Users 用户端Index/Dashboard - 数据链路回归:u/2张三2h明细正确显示 - tsc无错误 vite build成功
This commit is contained in:
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 408 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 408 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
interface BrandProps {
|
||||||
|
variant?: 'dark' | 'light';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showTitle?: boolean;
|
||||||
|
condensed?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Brand({
|
||||||
|
variant = 'dark',
|
||||||
|
size = 'md',
|
||||||
|
showTitle = true,
|
||||||
|
condensed = false,
|
||||||
|
className = '',
|
||||||
|
}: BrandProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: { logo: 'w-[28px] h-auto', title: 'text-sm font-semibold' },
|
||||||
|
md: { logo: 'w-[36px] h-auto', title: 'text-base font-bold' },
|
||||||
|
lg: { logo: 'w-[44px] h-auto', title: 'text-xl font-bold tracking-tight' },
|
||||||
|
}[size];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2.5 ${className}`}>
|
||||||
|
<div className="shrink-0 overflow-hidden" style={{ maxWidth: '60px' }}>
|
||||||
|
<img
|
||||||
|
src={variant === 'dark' ? '/logo-dark.svg' : '/logo-light.svg'}
|
||||||
|
alt="宣传部义工"
|
||||||
|
className={sizes.logo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showTitle && !condensed && (
|
||||||
|
<span
|
||||||
|
className={`${variant === 'light' ? 'text-white' : 'text-gray-900'} ${sizes.title} leading-tight whitespace-nowrap`}
|
||||||
|
>
|
||||||
|
宣传部义工时长记录系统
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,24 +8,24 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
|
|
||||||
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 =
|
const 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';
|
'inline-flex items-center justify-center font-semibold transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed border';
|
||||||
const sizeCls =
|
const sizeCls =
|
||||||
size === 'sm'
|
size === 'sm'
|
||||||
? 'px-3 py-1.5 text-sm shadow-sm'
|
? 'px-3 py-1.5 text-sm rounded'
|
||||||
: size === 'lg'
|
: size === 'lg'
|
||||||
? 'px-6 py-3 text-lg shadow'
|
? 'px-6 py-3 text-lg rounded-md'
|
||||||
: 'px-4 py-2 text-base shadow';
|
: 'px-4 py-2 text-base rounded-md';
|
||||||
const variantCls = {
|
const variantCls = {
|
||||||
primary:
|
primary:
|
||||||
'bg-blue-600 text-white hover:bg-blue-700 hover:border-blue-800 shadow',
|
'bg-[#275354] text-white border-[#275354] hover:bg-[#1e4041] hover:border-[#1e4041] shadow-sm',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-black hover:bg-gray-50 hover:border-gray-700 shadow-sm',
|
'bg-white text-gray-800 border-gray-300 hover:bg-gray-50 hover:border-gray-400 shadow-sm',
|
||||||
danger:
|
danger:
|
||||||
'bg-red-500 text-white hover:bg-red-600 hover:border-red-700 shadow',
|
'bg-red-500 text-white border-red-500 hover:bg-red-600 hover:border-red-600 shadow-sm',
|
||||||
ghost:
|
ghost:
|
||||||
'bg-transparent text-black border-transparent hover:bg-gray-100 shadow-none active:translate-x-0 active:translate-y-0',
|
'bg-transparent text-gray-700 border-transparent hover:bg-gray-100 hover:border-gray-200 shadow-none',
|
||||||
amber:
|
amber:
|
||||||
'bg-amber-400 text-black hover:bg-amber-500 shadow-amber',
|
'bg-amber-50 text-amber-800 border-amber-300 hover:bg-amber-100 shadow-sm',
|
||||||
}[variant];
|
}[variant];
|
||||||
return (
|
return (
|
||||||
<button {...rest} className={`${base} ${sizeCls} ${variantCls} ${className}`}>
|
<button {...rest} className={`${base} ${sizeCls} ${variantCls} ${className}`}>
|
||||||
|
|||||||
@@ -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-black bg-white shadow-sm ${className}`}>
|
<div className={`w-full overflow-x-auto border border-gray-200 bg-white rounded-lg 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-black text-white border-b-2 border-black">
|
<tr className="bg-gray-100 text-gray-800 border-b border-gray-200">
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<th key={c.key} style={c.width ? { width: c.width } : undefined} className="px-3 py-3 text-left font-black whitespace-nowrap">
|
<th key={c.key} style={c.width ? { width: c.width } : undefined} className="px-3 py-3 text-left font-semibold whitespace-nowrap">
|
||||||
{c.title}
|
{c.title}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -38,7 +38,7 @@ export default function DataTable<T>({ columns, data, emptyText = '暂无数据'
|
|||||||
data.map((row, idx) => (
|
data.map((row, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={rowKey ? rowKey(row, idx) : idx}
|
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'}`}
|
className={`border-b border-gray-200 hover:bg-primary-50 transition-colors ${idx % 2 ? 'bg-gray-50' : 'bg-white'}`}
|
||||||
>
|
>
|
||||||
{columns.map((c) => (
|
{columns.map((c) => (
|
||||||
<td key={c.key} className="px-3 py-3 align-top">
|
<td key={c.key} className="px-3 py-3 align-top">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
{...rest}
|
{...rest}
|
||||||
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}`}
|
className={`w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 text-sm md:text-base transition-all focus:border-[#275354] focus:ring-2 focus:ring-[#275354]/20 outline-none placeholder:text-gray-400 ${className}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ export default function Modal({ open, title, children, onClose, footer, width =
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
|
<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={`bg-white w-full ${width} max-h-[90vh] flex flex-col border border-gray-200 rounded-xl shadow-lg`} 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 border-gray-200 bg-gray-50 rounded-t-xl">
|
||||||
<h3 className="font-bold text-lg">{title}</h3>
|
<h3 className="font-semibold text-lg text-gray-800">{title}</h3>
|
||||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label="关闭"><IconClose size={18} /></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 border-gray-200 bg-gray-50 rounded-b-xl flex justify-end gap-2 flex-wrap">{footer}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ 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.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}`}>
|
<select
|
||||||
|
{...rest}
|
||||||
|
className={`w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm md:text-base transition-all focus:border-[#275354] focus:ring-2 focus:ring-[#275354]/20 outline-none appearance-none bg-no-repeat bg-[right_0.75rem_center] pr-9 ${className}`}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ 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.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}`} />
|
<textarea
|
||||||
|
{...rest}
|
||||||
|
className={`w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-sm md:text-base transition-all focus:border-[#275354] focus:ring-2 focus:ring-[#275354]/20 outline-none placeholder:text-gray-400 ${className}`}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-25
@@ -1,9 +1,9 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=JetBrains+Mono:wght@500;700&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@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');
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@@ -16,22 +16,14 @@ body {
|
|||||||
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif;
|
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
background-color: #fafaf6;
|
background-color: #F6F8F9;
|
||||||
background-image:
|
color: #0F172A;
|
||||||
radial-gradient(circle at 1px 1px, rgba(15, 23, 42, 0.06) 1px, transparent 0);
|
|
||||||
background-size: 22px 22px;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-display {
|
|
||||||
font-family: 'Instrument Serif', 'Noto Serif SC', 'Source Han Serif', 'Songti SC', SimSun, serif;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.25);
|
box-shadow: 0 0 0 3px rgba(39, 83, 84, 0.2);
|
||||||
border-color: #2563EB;
|
border-color: #275354;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -39,32 +31,41 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 12px;
|
width: 10px;
|
||||||
height: 12px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f5f9;
|
background: #F1F5F9;
|
||||||
border-left: 1px solid #cbd5e1;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #0f172a;
|
background: #275354;
|
||||||
border: 2px solid #f1f5f9;
|
border: 2px solid #F1F5F9;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #1d4ed8;
|
background: #1E4041;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply bg-white border-2 border-black shadow-sm;
|
@apply bg-white border border-gray-200 rounded-lg shadow-sm;
|
||||||
}
|
}
|
||||||
.surface {
|
.surface {
|
||||||
@apply bg-paper border-2 border-black;
|
@apply bg-paper border border-gray-200 rounded-lg;
|
||||||
}
|
}
|
||||||
.chip {
|
.chip {
|
||||||
@apply inline-flex items-center px-2.5 py-1 text-xs font-bold border-2 border-black bg-white;
|
@apply inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md border border-gray-200 bg-white text-gray-700;
|
||||||
|
}
|
||||||
|
.chip-primary {
|
||||||
|
@apply inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md border border-primary-200 bg-primary-50 text-primary-700;
|
||||||
}
|
}
|
||||||
.paper {
|
.paper {
|
||||||
@apply bg-white border-2 border-black shadow;
|
@apply bg-white border border-gray-200 rounded-lg shadow;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
@apply text-lg font-semibold text-gray-800 tracking-tight;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
@apply text-sm text-gray-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,10 +101,12 @@ export default function Activities() {
|
|||||||
<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">
|
<div className="flex items-start gap-2">
|
||||||
<IconCalendar size={28} className="mt-0.5 shrink-0" />
|
<div className="shrink-0 p-2 bg-primary-50 text-primary-600 rounded-md mt-0.5">
|
||||||
|
<IconCalendar size={26} />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl md:text-2xl font-black mb-1">活动设置</h1>
|
<h1 className="section-title">活动设置</h1>
|
||||||
<p className="text-sm text-gray-500">发布、管理活动,并设置参与的工作组</p>
|
<p className="muted mt-1">发布、管理活动,并设置参与的工作组</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
@@ -115,7 +117,7 @@ export default function Activities() {
|
|||||||
|
|
||||||
<DataTable<ActivityItem>
|
<DataTable<ActivityItem>
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'title', title: '活动标题', width: '26%', render: (r) => <span className="font-bold">{r.title}</span> },
|
{ key: 'title', title: '活动标题', width: '26%', render: (r) => <span className="font-semibold">{r.title}</span> },
|
||||||
{ key: 'time', title: '活动时间', render: (r) => formatDateRange(r.start_time, r.end_time) },
|
{ 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: '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: 'created_at', title: '创建时间', width: '18%', render: (r) => String(r.created_at || '').slice(0, 16) },
|
||||||
@@ -135,28 +137,28 @@ export default function Activities() {
|
|||||||
<Modal open={modalOpen} title={editId ? '编辑活动' : '新建活动'} onClose={() => setModalOpen(false)}
|
<Modal open={modalOpen} title={editId ? '编辑活动' : '新建活动'} onClose={() => setModalOpen(false)}
|
||||||
footer={<><Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button><Button onClick={submit}>{editId ? '保存修改' : '发布活动'}</Button></>}>
|
footer={<><Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button><Button onClick={submit}>{editId ? '保存修改' : '发布活动'}</Button></>}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div><label className="block text-sm font-bold mb-1.5">活动标题 *</label>
|
<div><label className="block text-sm font-semibold mb-1.5">活动标题 *</label>
|
||||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:2026 元旦联欢志愿服务" />
|
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="如:2026 元旦联欢志愿服务" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div><label className="block text-sm font-bold mb-1.5">开始时间 *</label>
|
<div><label className="block text-sm font-semibold mb-1.5">开始时间 *</label>
|
||||||
<Input type="datetime-local" value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} />
|
<Input type="datetime-local" value={form.start_time} onChange={(e) => setForm({ ...form, start_time: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-sm font-bold mb-1.5">结束时间 *</label>
|
<div><label className="block text-sm font-semibold mb-1.5">结束时间 *</label>
|
||||||
<Input type="datetime-local" value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} />
|
<Input type="datetime-local" value={form.end_time} onChange={(e) => setForm({ ...form, end_time: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-sm font-bold mb-1.5">活动内容</label>
|
<div><label className="block text-sm font-semibold mb-1.5">活动内容</label>
|
||||||
<Textarea rows={3} value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} />
|
<Textarea rows={3} value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold mb-1.5">参与的工作组(不选则默认全部部员可选)</label>
|
<label className="block text-sm font-semibold mb-1.5">参与的工作组(不选则默认全部部员可选)</label>
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<div className="text-sm text-gray-500 p-3 border-2 border-dashed border-gray-300">暂无工作组,可先去「管理工作组」添加</div>
|
<div className="text-sm text-gray-500 p-3 border border-dashed border-gray-300 rounded-md bg-gray-50">暂无工作组,可先去「管理工作组」添加</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{groups.map((g) => (
|
{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'}`}>
|
<label key={g.id} className={`flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer transition-colors ${form.groups.includes(g.id) ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'}`}>
|
||||||
<input type="checkbox" className="hidden" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
|
<input type="checkbox" className="hidden" checked={form.groups.includes(g.id)} onChange={() => toggleGroup(g.id)} />
|
||||||
<span className="text-sm font-medium">{g.name}</span>
|
<span className="text-sm font-medium">{g.name}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ type Category = {
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
accent: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORIES: Category[] = [
|
const CATEGORIES: Category[] = [
|
||||||
@@ -47,8 +46,7 @@ const CATEGORIES: Category[] = [
|
|||||||
title: '部员名册',
|
title: '部员名册',
|
||||||
subtitle: 'Users',
|
subtitle: 'Users',
|
||||||
desc: '导出所有部员的班级、姓名、义工号与所属工作组',
|
desc: '导出所有部员的班级、姓名、义工号与所属工作组',
|
||||||
icon: <IconUsers size={32} />,
|
icon: <IconUsers size={28} />,
|
||||||
accent: 'from-blue-500 to-blue-700',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'include_activities',
|
key: 'include_activities',
|
||||||
@@ -56,8 +54,7 @@ const CATEGORIES: Category[] = [
|
|||||||
title: '活动列表',
|
title: '活动列表',
|
||||||
subtitle: 'Activities',
|
subtitle: 'Activities',
|
||||||
desc: '导出所有活动的标题、时间、参与工作组',
|
desc: '导出所有活动的标题、时间、参与工作组',
|
||||||
icon: <IconCalendar size={32} />,
|
icon: <IconCalendar size={28} />,
|
||||||
accent: 'from-amber-500 to-amber-700',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'include_hours',
|
key: 'include_hours',
|
||||||
@@ -65,8 +62,7 @@ const CATEGORIES: Category[] = [
|
|||||||
title: '义工时长明细',
|
title: '义工时长明细',
|
||||||
subtitle: 'All Hours',
|
subtitle: 'All Hours',
|
||||||
desc: '所有部员在所有活动中的时长登记记录(含备注)',
|
desc: '所有部员在所有活动中的时长登记记录(含备注)',
|
||||||
icon: <IconTimer size={32} />,
|
icon: <IconTimer size={28} />,
|
||||||
accent: 'from-black to-gray-800',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'include_user_totals',
|
key: 'include_user_totals',
|
||||||
@@ -74,8 +70,7 @@ const CATEGORIES: Category[] = [
|
|||||||
title: '部员总时长',
|
title: '部员总时长',
|
||||||
subtitle: 'User Totals',
|
subtitle: 'User Totals',
|
||||||
desc: '所有部员各自的累计总时长与活动数(按时长降序排名)',
|
desc: '所有部员各自的累计总时长与活动数(按时长降序排名)',
|
||||||
icon: <IconClock size={32} />,
|
icon: <IconClock size={28} />,
|
||||||
accent: 'from-green-500 to-green-700',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'include_user_activities',
|
key: 'include_user_activities',
|
||||||
@@ -83,8 +78,7 @@ const CATEGORIES: Category[] = [
|
|||||||
title: '部员活动明细',
|
title: '部员活动明细',
|
||||||
subtitle: 'User Activities',
|
subtitle: 'User Activities',
|
||||||
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
|
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
|
||||||
icon: <IconEye size={32} />,
|
icon: <IconEye size={28} />,
|
||||||
accent: 'from-red-500 to-red-700',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -222,13 +216,13 @@ export default function Export() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header className="mb-2">
|
<header className="mb-2">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="shrink-0 p-3 bg-black text-white shadow-lg shadow">
|
<div className="shrink-0 p-3 bg-primary-50 text-primary-600 rounded-lg shadow-sm">
|
||||||
<IconExport size={32} />
|
<IconExport size={28} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl md:text-3xl font-black tracking-tight">数据导出中心</h1>
|
<h1 className="section-title">数据导出中心</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="muted mt-1">
|
||||||
勾选需要的导出类别(已选 <span className="font-black text-blue-700">{count}</span> 项),
|
勾选需要的导出类别(已选 <span className="font-semibold text-primary-600">{count}</span> 项),
|
||||||
生成预览后一键导出为 Excel 表格或 PNG 图片
|
生成预览后一键导出为 Excel 表格或 PNG 图片
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,31 +235,33 @@ export default function Export() {
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={c.key}
|
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'}`}
|
className={`group relative block cursor-pointer transition-all duration-150 ${active ? '' : 'hover:shadow-md'}`}
|
||||||
style={{ animationDelay: `${idx * 40}ms` }}
|
style={{ animationDelay: `${idx * 40}ms` }}
|
||||||
>
|
>
|
||||||
<input type="checkbox" className="sr-only" checked={active} onChange={() => toggle(c.key)} />
|
<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={`h-full card transition-all ${active ? 'border-primary-500 ring-1 ring-primary-500/20' : ''}`}>
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
<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={`flex items-start gap-4 p-4 pb-3 ${active ? 'bg-primary-50' : 'bg-gray-50/70 border-b border-gray-100'}`}>
|
||||||
<div className={`shrink-0 p-3 border-2 ${active ? 'border-white text-white' : 'border-black bg-white text-black shadow-sm'}`}>
|
<div className={`shrink-0 p-2.5 rounded-md border transition-all ${active ? 'border-primary-300 bg-white text-primary-600 shadow-sm' : 'border-gray-200 bg-white text-gray-700'}`}>
|
||||||
{c.icon}
|
{c.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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="flex items-center gap-2 mb-0.5">
|
||||||
<div className={`text-lg md:text-xl font-black tracking-tight leading-tight ${active ? 'text-white' : 'text-black'}`}>{c.title}</div>
|
<span className={`chip-primary`}>{c.tag}</span>
|
||||||
<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`}>
|
</div>
|
||||||
|
<div className={`text-lg font-semibold tracking-tight leading-tight ${active ? 'text-gray-900' : 'text-gray-800'}`}>{c.title}</div>
|
||||||
|
<div className={`text-[11px] md:text-xs font-mono tracking-wider mt-0.5 ${active ? 'text-primary-600' : 'text-gray-500'}`}>
|
||||||
{c.subtitle}
|
{c.subtitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-1 shrink-0">
|
<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'}`}>
|
<div className={`w-6 h-6 grid place-items-center rounded border transition-all ${active ? 'border-primary-500 text-white bg-primary-500' : 'border-gray-300 text-transparent bg-white'}`}>
|
||||||
{active ? 'OK' : ''}
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 pt-3 bg-white">
|
<div className="p-4 pt-3 bg-white">
|
||||||
<div className="text-sm text-gray-700 leading-relaxed">{c.desc}</div>
|
<div className="text-sm text-gray-600 leading-relaxed">{c.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,9 +270,9 @@ export default function Export() {
|
|||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="paper p-4 md:p-5 bg-white">
|
<section className="card p-4 md:p-5">
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="muted">
|
||||||
操作:先勾选上方类别卡片,生成预览后导出文件。
|
操作:先勾选上方类别卡片,生成预览后导出文件。
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap justify-end">
|
<div className="flex gap-2 flex-wrap justify-end">
|
||||||
@@ -294,55 +290,55 @@ export default function Export() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{preview && (
|
{preview && (
|
||||||
<div ref={previewRef} className="paper bg-white p-3 md:p-6 space-y-8">
|
<div ref={previewRef} className="card bg-white p-3 md:p-6 space-y-8">
|
||||||
<div className="border-b-2 border-black pb-4 text-center">
|
<div className="border-b border-gray-200 pb-4 text-center">
|
||||||
<div className="font-display text-3xl md:text-4xl font-black tracking-tight italic">Volunteer Data Report</div>
|
<div className="text-xl md:text-2xl font-semibold tracking-tight text-gray-800">宣传部义工时长记录系统 · 数据导出报告</div>
|
||||||
<div className="text-sm text-gray-600 mt-1">义工数据汇总导出 | 生成时间:{new Date().toLocaleString('zh-CN')}</div>
|
<div className="muted mt-1.5">义工数据汇总导出 | 生成时间:{new Date().toLocaleString('zh-CN')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{preview.users?.length ? (
|
{preview.users?.length ? (
|
||||||
<section className="animate-stagger-in">
|
<section className="animate-fade-in-up">
|
||||||
<h3 className="font-black text-xl mb-3 border-l-4 border-blue-600 pl-3 inline-flex items-center gap-2">
|
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||||||
<span className="chip">01</span> 部员名册({preview.users.length} 人)
|
<span className="chip-primary">01</span> 部员名册({preview.users.length} 人)
|
||||||
</h3>
|
</h3>
|
||||||
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-'])} />
|
<MiniTable heads={['序号', '班级', '姓名', '义工号', '工作组']} rows={preview.users.map((u, i) => [i + 1, u.class, u.name, u.volunteer_no || '-', u.group_name || '-'])} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{preview.activities?.length ? (
|
{preview.activities?.length ? (
|
||||||
<section className="animate-stagger-in">
|
<section className="animate-fade-in-up">
|
||||||
<h3 className="font-black text-xl mb-3 border-l-4 border-amber-600 pl-3 inline-flex items-center gap-2">
|
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||||||
<span className="chip">02</span> 活动列表({preview.activities.length} 个)
|
<span className="chip-primary">02</span> 活动列表({preview.activities.length} 个)
|
||||||
</h3>
|
</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}
|
) : null}
|
||||||
{preview.hours?.length ? (
|
{preview.hours?.length ? (
|
||||||
<section className="animate-stagger-in">
|
<section className="animate-fade-in-up">
|
||||||
<h3 className="font-black text-xl mb-3 border-l-4 border-black pl-3 inline-flex items-center gap-2">
|
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||||||
<span className="chip">03</span> 全部时长明细({preview.hours.length} 条)
|
<span className="chip-primary">03</span> 全部时长明细({preview.hours.length} 条)
|
||||||
</h3>
|
</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 || '-'])} />
|
<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}
|
) : null}
|
||||||
{preview.user_totals?.length ? (
|
{preview.user_totals?.length ? (
|
||||||
<section className="animate-stagger-in">
|
<section className="animate-fade-in-up">
|
||||||
<h3 className="font-black text-xl mb-3 border-l-4 border-green-600 pl-3 inline-flex items-center gap-2">
|
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||||||
<span className="chip">04</span> 部员总时长排行
|
<span className="chip-primary">04</span> 部员总时长排行
|
||||||
</h3>
|
</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)])} />
|
<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}
|
) : null}
|
||||||
{preview.user_activities?.length ? (
|
{preview.user_activities?.length ? (
|
||||||
<section className="animate-stagger-in">
|
<section className="animate-fade-in-up">
|
||||||
<h3 className="font-black text-xl mb-3 border-l-4 border-red-600 pl-3 inline-flex items-center gap-2">
|
<h3 className="font-semibold text-lg mb-3 border-l-4 border-primary-500 pl-3 inline-flex items-center gap-2 text-gray-800">
|
||||||
<span className="chip">05</span> 部员参加的活动
|
<span className="chip-primary">05</span> 部员参加的活动
|
||||||
</h3>
|
</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-black bg-gray-50 shadow-sm">
|
<div key={u.id} className="card overflow-hidden">
|
||||||
<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">
|
<div className="bg-[#275354] text-white px-4 py-2.5 border-b border-gray-200 font-semibold flex items-center justify-between flex-wrap gap-2">
|
||||||
<span className="font-black">{u.class} {u.name}{u.group_name ? ` (${u.group_name})` : ''}</span>
|
<span>{u.class} {u.name}{u.group_name ? ` (${u.group_name})` : ''}</span>
|
||||||
<span className="text-xs font-bold text-gray-300 font-mono tracking-wider">共 {u.activities.length} 次活动</span>
|
<span className="text-xs font-medium text-white/85 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>
|
||||||
@@ -362,12 +358,12 @@ 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 border-2 border-black bg-white">
|
<div className="w-full overflow-x-auto border border-gray-200 bg-white rounded-lg">
|
||||||
<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 border-gray-200">
|
||||||
{heads.map((h, i) => (
|
{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">
|
<th key={i} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">
|
||||||
{h}
|
{h}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -375,9 +371,9 @@ function MiniTable({ heads, rows }: { heads: string[]; rows: any[][] }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r, ri) => (
|
{rows.map((r, ri) => (
|
||||||
<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'}>
|
<tr key={ri} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||||||
{r.map((c, ci) => (
|
{r.map((c, ci) => (
|
||||||
<td key={ci} className="px-3 py-2 border border-gray-200 whitespace-nowrap">
|
<td key={ci} className="px-3 py-2 whitespace-nowrap text-gray-700">
|
||||||
{c}
|
{c}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -108,17 +108,19 @@ export default function Hours() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-5 flex items-start gap-2">
|
<div className="mb-5 flex items-start gap-2">
|
||||||
<IconClock size={28} className="mt-0.5 shrink-0" />
|
<div className="shrink-0 p-2 bg-primary-50 text-primary-600 rounded-md mt-0.5">
|
||||||
|
<IconClock size={26} />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl md:text-2xl font-black mb-1">义工时长登记</h1>
|
<h1 className="section-title">义工时长登记</h1>
|
||||||
<p className="text-sm text-gray-500">选择活动 → 填写每位部员的时长与备注 → 保存并导出</p>
|
<p className="muted mt-1">选择活动 → 填写每位部员的时长与备注 → 保存并导出</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border-2 border-black p-4 mb-4">
|
<div className="card 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">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold mb-1.5">选择活动</label>
|
<label className="block text-sm font-semibold mb-1.5 text-gray-700">选择活动</label>
|
||||||
<Select value={String(selectedId)} onChange={(e) => setSelectedId(e.target.value === '' ? '' : Number(e.target.value))}>
|
<Select value={String(selectedId)} onChange={(e) => setSelectedId(e.target.value === '' ? '' : Number(e.target.value))}>
|
||||||
<option value="">-- 请选择活动 --</option>
|
<option value="">-- 请选择活动 --</option>
|
||||||
{activities.map(a => <option key={a.id} value={a.id}>{a.title} ({formatDateRange(a.start_time, a.end_time)})</option>)}
|
{activities.map(a => <option key={a.id} value={a.id}>{a.title} ({formatDateRange(a.start_time, a.end_time)})</option>)}
|
||||||
@@ -132,11 +134,11 @@ export default function Hours() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activityInfo && (
|
{activityInfo && (
|
||||||
<div className="mt-4 pt-4 border-t-2 border-gray-200 grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="mt-4 pt-4 border-t 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="muted mb-1">活动名称</div><div className="font-semibold 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="muted mb-1">活动时间</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="muted mb-1">已登记/总人数</div><div className="font-semibold">{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><div className="muted mb-1">总时长</div><div className="font-semibold text-primary-600">{summary.hours.toFixed(2)} 小时</div></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>}
|
{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>}
|
||||||
@@ -145,43 +147,43 @@ export default function Hours() {
|
|||||||
{loading && <div className="p-10 text-center text-gray-500">加载中...</div>}
|
{loading && <div className="p-10 text-center text-gray-500">加载中...</div>}
|
||||||
|
|
||||||
{!loading && selectedId && (
|
{!loading && selectedId && (
|
||||||
<div ref={tableRef} className="bg-white p-2 md:p-4 border-2 border-gray-200">
|
<div ref={tableRef} className="card p-2 md:p-4">
|
||||||
{activityInfo && (
|
{activityInfo && (
|
||||||
<div className="mb-3 pb-3 border-b-2 border-black hidden md:block">
|
<div className="mb-3 pb-3 border-b border-gray-200 hidden md:block">
|
||||||
<div className="text-center font-black text-xl mb-1">义工时长登记表</div>
|
<div className="text-center font-semibold text-lg mb-1 text-gray-800">义工时长登记表</div>
|
||||||
<div className="text-center text-sm text-gray-700">活动:{activityInfo.title} | 时间:{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div>
|
<div className="text-center muted">活动:{activityInfo.title} | 时间:{formatDateRange(activityInfo.start_time, activityInfo.end_time)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto rounded-lg">
|
||||||
<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 border-gray-200">
|
||||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">序号</th>
|
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">序号</th>
|
||||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">班级</th>
|
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">班级</th>
|
||||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">姓名</th>
|
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">姓名</th>
|
||||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">义工号</th>
|
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">义工号</th>
|
||||||
<th className="px-2 py-2 text-left font-bold border border-gray-200 whitespace-nowrap">工作组</th>
|
<th className="px-2 py-2.5 text-left font-semibold whitespace-nowrap text-gray-800">工作组</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.5 text-left font-semibold whitespace-nowrap text-gray-800" style={{ minWidth: '110px' }}>时长(小时)</th>
|
||||||
<th className="px-2 py-2 text-left font-bold border border-gray-200" style={{ minWidth: '200px' }}>备注</th>
|
<th className="px-2 py-2.5 text-left font-semibold text-gray-800" style={{ minWidth: '200px' }}>备注</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{participants.length === 0 ? (
|
{participants.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="px-3 py-10 text-center text-gray-500 border border-gray-200">
|
<tr><td colSpan={7} className="px-3 py-10 text-center text-gray-500 border-t border-gray-200">
|
||||||
该活动暂无部员可登记(若选了工作组,请确认该工作组下有部员)
|
该活动暂无部员可登记(若选了工作组,请确认该工作组下有部员)
|
||||||
</td></tr>
|
</td></tr>
|
||||||
) : participants.map((p, idx) => (
|
) : participants.map((p, idx) => (
|
||||||
<tr key={p.id} className="border-b border-gray-200">
|
<tr key={p.id} className={`border-b border-gray-200 ${idx % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||||||
<td className="px-2 py-2 border border-gray-200">{idx + 1}</td>
|
<td className="px-2 py-2 text-gray-700">{idx + 1}</td>
|
||||||
<td className="px-2 py-2 border border-gray-200 whitespace-nowrap">{p.class}</td>
|
<td className="px-2 py-2 whitespace-nowrap text-gray-700">{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 font-semibold whitespace-nowrap text-gray-800">{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 whitespace-nowrap text-gray-700">{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-2 whitespace-nowrap text-gray-700">{p.group_name || '—'}</td>
|
||||||
<td className="px-2 py-1 border border-gray-200">
|
<td className="px-2 py-1.5">
|
||||||
<Input type="number" step="0.1" min="0" value={String(p.hours)} onChange={(e) => updateRow(idx, 'hours', e.target.value)} className="py-1" />
|
<Input type="number" step="0.1" min="0" value={String(p.hours)} onChange={(e) => updateRow(idx, 'hours', e.target.value)} className="py-1.5" />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 border border-gray-200">
|
<td className="px-2 py-1.5">
|
||||||
<Input value={p.remark} onChange={(e) => updateRow(idx, 'remark', e.target.value)} placeholder="工作内容/备注" className="py-1" />
|
<Input value={p.remark} onChange={(e) => updateRow(idx, 'remark', e.target.value)} placeholder="工作内容/备注" className="py-1.5" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -192,7 +194,7 @@ export default function Hours() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedId && !loading && (
|
{!selectedId && !loading && (
|
||||||
<div className="bg-white border-2 border-dashed border-gray-300 p-10 text-center text-gray-500">
|
<div className="card border-dashed border-gray-300 p-10 text-center text-gray-500 bg-gray-50/50">
|
||||||
请先在上方选择一个活动开始登记
|
请先在上方选择一个活动开始登记
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ 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 Brand from '../../components/Brand';
|
||||||
import { authApi, errMsg } from '../../api';
|
import { authApi, errMsg } from '../../api';
|
||||||
import { IconCalendar, IconClock, IconUsers, IconExport, IconLogo, IconClose, IconMenu } from '../../components/Icons';
|
import { IconCalendar, IconClock, IconUsers, IconExport, IconClose, IconMenu } from '../../components/Icons';
|
||||||
|
|
||||||
type MenuItem = { path: string; label: string; icon: ReactNode };
|
type MenuItem = { path: string; label: string; icon: ReactNode };
|
||||||
|
|
||||||
@@ -55,45 +56,38 @@ export default function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col md:flex-row bg-paper">
|
<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 shadow">
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-[#275354] text-white shadow-sm">
|
||||||
<div className="flex items-center gap-2 font-black text-lg">
|
<Brand variant="light" size="sm" condensed={false} showTitle={true} />
|
||||||
<IconLogo size={22} />
|
|
||||||
<span className="tracking-wide">义工管理</span>
|
|
||||||
</div>
|
|
||||||
<button className="text-white" onClick={() => setMenuOpen(v => !v)} aria-label="菜单">
|
<button className="text-white" onClick={() => setMenuOpen(v => !v)} aria-label="菜单">
|
||||||
{menuOpen ? <IconClose size={22} /> : <IconMenu size={22} />}
|
{menuOpen ? <IconClose size={22} /> : <IconMenu size={22} />}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<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`}>
|
<aside className={`${menuOpen ? 'block animate-fade-in-up' : 'hidden'} md:block w-full md:w-64 shrink-0 bg-[#275354] text-white md:min-h-screen border-b md:border-b-0 md:border-r md:border-[#1e4041] md:sticky md:top-0 md:h-screen flex flex-col`}>
|
||||||
<div className="hidden md:block p-5 border-b-2 border-white/15 bg-black">
|
<div className="hidden md:block p-5 border-b border-white/10 bg-[#1e4041]/40">
|
||||||
<div className="flex items-center gap-2.5 font-black text-xl tracking-wide text-white">
|
<Brand variant="light" size="md" showTitle={true} />
|
||||||
<IconLogo size={24} />
|
|
||||||
<span className="font-display italic">Volunteer</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-400">义工时长管理系统</div>
|
<nav className="p-3 space-y-1 flex-1">
|
||||||
</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-bold border-2 transition-all ${
|
`flex items-center gap-3 px-4 py-2.5 text-sm font-medium rounded-md transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-600 text-white border-blue-400 shadow-sm translate-x-[2px]'
|
? 'bg-primary-600 text-white shadow-sm'
|
||||||
: 'text-gray-100 border-transparent hover:bg-white/10 hover:border-white/20 hover:text-white'
|
: 'text-gray-100 hover:bg-white/10 hover:text-white'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="inline-flex">{m.icon}</span>
|
<span className="inline-flex opacity-90">{m.icon}</span>
|
||||||
<span>{m.label}</span>
|
<span>{m.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<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="p-3 border-t border-white/10 bg-[#1e4041]/40 backdrop-blur">
|
||||||
<div className="text-xs text-gray-400 mb-2">当前管理员:<span className="font-bold text-white">{username}</span></div>
|
<div className="text-xs text-gray-300 mb-2">当前管理员:<span className="font-semibold text-white">{username}</span></div>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button size="sm" variant="ghost" className="flex-1 !text-gray-200 hover:!bg-white/10" 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>
|
||||||
@@ -113,13 +107,13 @@ export default function Layout() {
|
|||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<div className="space-y-4 max-w-sm">
|
<div className="space-y-4 max-w-sm">
|
||||||
<div><label className="block text-sm font-bold mb-1.5">原密码</label>
|
<div><label className="block text-sm font-semibold mb-1.5">原密码</label>
|
||||||
<Input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} />
|
<Input type="password" value={oldPwd} onChange={(e) => setOldPwd(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-sm font-bold mb-1.5">新密码(至少 6 位)</label>
|
<div><label className="block text-sm font-semibold 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 font-bold">{pwdErr}</div>}
|
{pwdErr && <div className="text-sm text-red-600 font-medium">{pwdErr}</div>}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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 Brand from '../../components/Brand';
|
||||||
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');
|
||||||
@@ -33,21 +33,20 @@ 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 py-8">
|
||||||
<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 card p-6 md:p-8 animate-fade-in-up">
|
||||||
<div className="text-center mb-6 flex flex-col items-center">
|
<div className="text-center mb-6 flex flex-col items-center">
|
||||||
<div className="mb-2 text-black"><IconLogo size={48} /></div>
|
<Brand variant="dark" size="lg" showTitle={true} className="mb-4" />
|
||||||
<h1 className="text-2xl font-black tracking-wide">义工时长管理系统</h1>
|
<h3 className="text-lg font-semibold text-gray-700 tracking-tight">管理员登录</h3>
|
||||||
<p className="text-gray-500 text-sm mt-1">管理台登录</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold mb-1.5">用户名</label>
|
<label className="block text-sm font-semibold mb-1.5 text-gray-700">用户名</label>
|
||||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold mb-1.5">密码</label>
|
<label className="block text-sm font-semibold mb-1.5 text-gray-700">密码</label>
|
||||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||||
@@ -58,7 +57,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-gray-500">
|
<div className="mt-6 text-center text-sm text-gray-500">
|
||||||
<p>默认账号:admin / admin123</p>
|
<p>默认账号:admin / admin123</p>
|
||||||
<a href="/u" className="inline-block mt-2 underline underline-offset-2 hover:text-black">← 返回用户端</a>
|
<a href="/u" className="inline-block mt-2 underline underline-offset-2 hover:text-[#275354] hover:decoration-[#275354]">← 返回用户端</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,14 +69,16 @@ export default function Users() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-5 flex items-start gap-2">
|
<div className="mb-5 flex items-start gap-2">
|
||||||
<IconUsers size={28} className="mt-0.5 shrink-0" />
|
<div className="shrink-0 p-2 bg-primary-50 text-primary-600 rounded-md mt-0.5">
|
||||||
|
<IconUsers size={26} />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl md:text-2xl font-black mb-1">用户管理</h1>
|
<h1 className="section-title">用户管理</h1>
|
||||||
<p className="text-sm text-gray-500">管理部员信息,查看个人累计义工时长与参与活动明细</p>
|
<p className="muted mt-1">管理部员信息,查看个人累计义工时长与参与活动明细</p>
|
||||||
</div>
|
</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="card p-4 mb-4 flex flex-col md:flex-row gap-3 md:items-center md:justify-between">
|
||||||
<Input placeholder="搜索 姓名/班级/义工号/工作组..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="md:max-w-md" />
|
<Input placeholder="搜索 姓名/班级/义工号/工作组..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="md:max-w-md" />
|
||||||
<Button onClick={openNew}><span className="inline-flex items-center gap-2"><IconPlus size={18} />新增部员</span></Button>
|
<Button onClick={openNew}><span className="inline-flex items-center gap-2"><IconPlus size={18} />新增部员</span></Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,12 +86,12 @@ export default function Users() {
|
|||||||
<DataTable<UserItem>
|
<DataTable<UserItem>
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'class', title: '班级', width: '120px', render: (r) => <span className="whitespace-nowrap">{r.class}</span> },
|
{ 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: 'name', title: '姓名', render: (r) => <span className="font-semibold">{r.name}</span> },
|
||||||
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
||||||
{ key: 'group_name', title: '所属工作组', render: (r) => r.group_name || '—' },
|
{ 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: 'total_hours', title: '累计时长(小时)', render: (r) => <span className="font-semibold text-primary-600">{Number(r.total_hours || 0).toFixed(2)}</span> },
|
||||||
{
|
{
|
||||||
key: 'op', title: '操作', width: '220px', render: (r) => (
|
key: 'op', title: '操作', width: '240px', 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)}><span className="inline-flex items-center gap-1.5"><IconEye size={14} />详情</span></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)}><span className="inline-flex items-center gap-1.5"><IconEdit size={14} />编辑</span></Button>
|
<Button size="sm" variant="secondary" onClick={() => openEdit(r)}><span className="inline-flex items-center gap-1.5"><IconEdit size={14} />编辑</span></Button>
|
||||||
@@ -106,17 +108,17 @@ export default function Users() {
|
|||||||
footer={<><Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button><Button onClick={submit}>{editId ? '保存修改' : '添加部员'}</Button></>}>
|
footer={<><Button variant="secondary" onClick={() => setModalOpen(false)}>取消</Button><Button onClick={submit}>{editId ? '保存修改' : '添加部员'}</Button></>}>
|
||||||
<div className="space-y-4 max-w-md">
|
<div className="space-y-4 max-w-md">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div><label className="block text-sm font-bold mb-1.5">班级 *</label>
|
<div><label className="block text-sm font-semibold mb-1.5">班级 *</label>
|
||||||
<Input value={form.class} onChange={(e) => setForm({ ...form, class: e.target.value })} placeholder="如:高三(1)班" />
|
<Input value={form.class} onChange={(e) => setForm({ ...form, class: e.target.value })} placeholder="如:高三(1)班" />
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-sm font-bold mb-1.5">姓名 *</label>
|
<div><label className="block text-sm font-semibold mb-1.5">姓名 *</label>
|
||||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-sm font-bold mb-1.5">义工号</label>
|
<div><label className="block text-sm font-semibold mb-1.5">义工号</label>
|
||||||
<Input value={form.volunteer_no} onChange={(e) => setForm({ ...form, volunteer_no: e.target.value })} />
|
<Input value={form.volunteer_no} onChange={(e) => setForm({ ...form, volunteer_no: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div><label className="block text-sm font-bold mb-1.5">所属工作组</label>
|
<div><label className="block text-sm font-semibold mb-1.5">所属工作组</label>
|
||||||
<Select value={String(form.group_id || '')} onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : null })}>
|
<Select value={String(form.group_id || '')} onChange={(e) => setForm({ ...form, group_id: e.target.value ? Number(e.target.value) : null })}>
|
||||||
<option value="">-- 不设置 --</option>
|
<option value="">-- 不设置 --</option>
|
||||||
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
@@ -130,26 +132,26 @@ export default function Users() {
|
|||||||
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}>
|
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}>
|
||||||
{detail && (
|
{detail && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
<div className="card p-3">
|
||||||
<div className="text-xs text-gray-500 mb-1">姓名</div><div className="font-black text-lg">{detail.name}</div>
|
<div className="muted mb-1">姓名</div><div className="font-semibold text-lg text-gray-800">{detail.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
<div className="card p-3">
|
||||||
<div className="text-xs text-gray-500 mb-1">班级</div><div className="font-bold">{detail.class}</div>
|
<div className="muted mb-1">班级</div><div className="font-semibold text-gray-800">{detail.class}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
<div className="card p-3">
|
||||||
<div className="text-xs text-gray-500 mb-1">义工号</div><div className="font-bold">{detail.volunteer_no || '—'}</div>
|
<div className="muted mb-1">义工号</div><div className="font-semibold text-gray-800">{detail.volunteer_no || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 border-2 border-gray-200 p-3">
|
<div className="card p-3 bg-primary-50/60">
|
||||||
<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 className="muted mb-1">累计时长</div><div className="font-semibold text-lg text-primary-600">{Number(detail.total_hours || 0).toFixed(2)}h</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold mb-2">参与的活动({detail.activities.length} 次)</h3>
|
<h3 className="font-semibold mb-2 text-gray-800">参与的活动({detail.activities.length} 次)</h3>
|
||||||
<DataTable columns={[
|
<DataTable columns={[
|
||||||
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium">{r.title}</span> },
|
{ 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: '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: 'hours', title: '时长', render: (r: any) => <span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(2)}h</span> },
|
||||||
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
|
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
|
||||||
]} data={detail.activities} rowKey={(r: any) => r.id} />
|
]} data={detail.activities} rowKey={(r: any) => r.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import Button from '../../components/Button';
|
import Button from '../../components/Button';
|
||||||
import DataTable from '../../components/DataTable';
|
import DataTable from '../../components/DataTable';
|
||||||
|
import Brand from '../../components/Brand';
|
||||||
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';
|
||||||
@@ -31,54 +32,58 @@ export default function UserDashboard() {
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 px-4 py-6 md:py-10">
|
<div className="min-h-screen bg-paper 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="mb-5 flex items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex items-start gap-2">
|
<Brand variant="dark" size="sm" showTitle={true} />
|
||||||
<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>
|
<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="mb-5 flex items-start gap-2 animate-fade-in-up">
|
||||||
|
<div className="shrink-0 p-2 bg-primary-50 text-primary-600 rounded-md mt-0.5">
|
||||||
|
<IconClock size={26} />
|
||||||
|
</div>
|
||||||
|
<h1 className="section-title mt-1">个人义工时长总览</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-5 mb-5 animate-fade-in-up">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 mb-1">姓名</div>
|
<div className="muted mb-1">姓名</div>
|
||||||
<div className="text-lg font-bold">{data.name}</div>
|
<div className="text-lg font-semibold text-gray-800">{data.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 mb-1">班级</div>
|
<div className="muted mb-1">班级</div>
|
||||||
<div className="text-lg font-bold">{data.class || '—'}</div>
|
<div className="text-lg font-semibold text-gray-800">{data.class || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 mb-1">义工号</div>
|
<div className="muted mb-1">义工号</div>
|
||||||
<div className="text-lg font-bold">{data.volunteer_no || '—'}</div>
|
<div className="text-lg font-semibold text-gray-800">{data.volunteer_no || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 mb-1">所属工作组</div>
|
<div className="muted mb-1">所属工作组</div>
|
||||||
<div className="text-lg font-bold">{data.group_name || '—'}</div>
|
<div className="text-lg font-semibold text-gray-800">{data.group_name || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 pt-5 border-t-2 border-gray-200 grid grid-cols-2 gap-4">
|
<div className="mt-5 pt-5 border-t border-gray-200 grid grid-cols-2 gap-4">
|
||||||
<div className="bg-gray-50 border-2 border-gray-200 p-4">
|
<div className="card p-4 bg-primary-50/50">
|
||||||
<div className="text-xs text-gray-500 mb-1">累计义工时长</div>
|
<div className="muted 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 className="text-2xl md:text-3xl font-semibold text-primary-600">{Number(data.total_hours || 0).toFixed(2)} <span className="text-base font-medium text-gray-600">小时</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 border-2 border-gray-200 p-4">
|
<div className="card p-4">
|
||||||
<div className="text-xs text-gray-500 mb-1">参与活动数</div>
|
<div className="muted 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 className="text-2xl md:text-3xl font-semibold text-gray-800">{data.activities.length} <span className="text-base font-medium text-gray-600">次</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="font-bold text-lg mb-3">参与活动明细</h2>
|
<h2 className="font-semibold text-lg mb-3 text-gray-800">参与活动明细</h2>
|
||||||
<DataTable
|
<DataTable
|
||||||
id="user-activities-table"
|
id="user-activities-table"
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium">{r.title}</span> },
|
{ key: 'title', title: '活动名称', render: (r: any) => <span className="font-medium text-gray-800">{r.title}</span> },
|
||||||
{ key: 'time', title: '活动时间', render: (r: any) => formatDateRange(r.start_time, r.end_time) },
|
{ 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: 'hours', title: '时长(小时)', render: (r: any) => <span className="font-semibold text-primary-600">{Number(r.hours || 0).toFixed(2)}</span> },
|
||||||
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
|
{ key: 'remark', title: '备注', render: (r: any) => r.remark || '—' },
|
||||||
]}
|
]}
|
||||||
data={data.activities}
|
data={data.activities}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ 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 DataTable from '../../components/DataTable';
|
import DataTable from '../../components/DataTable';
|
||||||
|
import Brand from '../../components/Brand';
|
||||||
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('');
|
||||||
@@ -30,19 +30,19 @@ 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-paper 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 flex flex-col items-center">
|
<div className="mb-8 text-center flex flex-col items-center animate-fade-in-up">
|
||||||
<div className="mb-3 text-black"><IconLogo size={64} /></div>
|
<Brand variant="dark" size="lg" showTitle={true} className="mb-3" />
|
||||||
<h1 className="text-2xl md:text-3xl font-black text-black tracking-wide">义工时长查询</h1>
|
<h2 className="text-base md:text-lg text-gray-600 tracking-tight">义工时长查询</h2>
|
||||||
<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">
|
||||||
<a href="/admin/login" className="text-gray-500 hover:text-black underline underline-offset-2">管理入口</a>
|
<a href="/admin/login" className="text-gray-500 hover:text-[#275354] underline underline-offset-2 decoration-gray-300 hover:decoration-[#275354]">管理入口</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={doSearch} className="bg-white border-2 border-black p-4 md:p-6">
|
<form onSubmit={doSearch} className="card p-4 md:p-6 animate-fade-in-up">
|
||||||
<label className="block text-sm font-bold mb-2">姓名</label>
|
<label className="block text-sm font-semibold mb-2 text-gray-700">姓名</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
@@ -57,20 +57,20 @@ export default function UserIndex() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{results !== null && (
|
{results !== null && (
|
||||||
<div className="mt-6">
|
<div className="mt-6 animate-fade-in-up">
|
||||||
<h2 className="font-bold mb-2 text-black">
|
<h2 className="font-semibold mb-2 text-gray-800">
|
||||||
查询结果:{results.length} 人
|
查询结果:{results.length} 人
|
||||||
{results.length === 0 && ' (未找到匹配的部员,请确认姓名是否正确)'}
|
{results.length === 0 && ' (未找到匹配的部员,请确认姓名是否正确)'}
|
||||||
</h2>
|
</h2>
|
||||||
{results.length === 1 ? (
|
{results.length === 1 ? (
|
||||||
<div className="bg-white border-2 border-black p-4">
|
<div className="card p-4">
|
||||||
<div className="flex justify-between items-center flex-wrap gap-3">
|
<div className="flex justify-between items-center flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-lg">{results[0].name}</div>
|
<div className="font-semibold text-lg text-gray-800">{results[0].name}</div>
|
||||||
<div className="text-sm text-gray-600 mt-1">
|
<div className="muted mt-1">
|
||||||
班级:{results[0].class || '—'} 义工号:{results[0].volunteer_no || '—'}
|
班级:{results[0].class || '—'} 义工号:{results[0].volunteer_no || '—'}
|
||||||
</div>
|
</div>
|
||||||
{results[0].group_name && <div className="text-sm text-gray-600 mt-0.5">工作组:{results[0].group_name}</div>}
|
{results[0].group_name && <div className="muted mt-0.5">工作组:{results[0].group_name}</div>}
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate(`/u/${results[0].id}`)}>查看详情</Button>
|
<Button onClick={() => navigate(`/u/${results[0].id}`)}>查看详情</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,7 @@ export default function UserIndex() {
|
|||||||
) : results.length > 1 ? (
|
) : results.length > 1 ? (
|
||||||
<DataTable<UserItem>
|
<DataTable<UserItem>
|
||||||
columns={[
|
columns={[
|
||||||
{ key: 'name', title: '姓名', render: (r) => <span className="font-bold">{r.name}</span> },
|
{ key: 'name', title: '姓名', render: (r) => <span className="font-semibold text-gray-800">{r.name}</span> },
|
||||||
{ key: 'class', title: '班级' },
|
{ key: 'class', title: '班级' },
|
||||||
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
{ key: 'volunteer_no', title: '义工号', render: (r) => r.volunteer_no || '—' },
|
||||||
{ key: 'group_name', title: '工作组', render: (r) => r.group_name || '—' },
|
{ key: 'group_name', title: '工作组', render: (r) => r.group_name || '—' },
|
||||||
|
|||||||
+39
-47
@@ -1,31 +1,44 @@
|
|||||||
import type { Config } from 'tailwindcss';
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const PRIMARY = '#275354';
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
DEFAULT: '0px',
|
|
||||||
none: '0px',
|
none: '0px',
|
||||||
sm: '0px',
|
sm: '2px',
|
||||||
md: '0px',
|
DEFAULT: '4px',
|
||||||
lg: '0px',
|
md: '6px',
|
||||||
xl: '0px',
|
lg: '8px',
|
||||||
'2xl': '0px',
|
xl: '12px',
|
||||||
|
'2xl': '16px',
|
||||||
full: '9999px',
|
full: '9999px',
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
DEFAULT: '4px 4px 0 0 #0f172a',
|
sm: '0 1px 2px 0 rgba(15, 23, 42, 0.06), 0 1px 1px -1px rgba(15, 23, 42, 0.06)',
|
||||||
sm: '2px 2px 0 0 #0f172a',
|
DEFAULT: '0 1px 3px 0 rgba(15, 23, 42, 0.08), 0 1px 2px -1px rgba(15, 23, 42, 0.05)',
|
||||||
lg: '6px 6px 0 0 #0f172a',
|
md: '0 4px 6px -2px rgba(15, 23, 42, 0.08), 0 2px 4px -3px rgba(15, 23, 42, 0.05)',
|
||||||
xl: '8px 8px 0 0 #0f172a',
|
lg: '0 10px 15px -3px rgba(15, 23, 42, 0.1), 0 4px 6px -4px rgba(15, 23, 42, 0.05)',
|
||||||
none: '0 0 0 0 #000',
|
xl: '0 20px 25px -5px rgba(15, 23, 42, 0.12), 0 8px 10px -6px rgba(15, 23, 42, 0.06)',
|
||||||
amber: '4px 4px 0 0 #b45309',
|
none: 'none',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
black: '#0f172a',
|
ink: '#0F172A',
|
||||||
ink: '#020617',
|
|
||||||
white: '#FFFFFF',
|
white: '#FFFFFF',
|
||||||
paper: '#fafaf6',
|
paper: '#F6F8F9',
|
||||||
|
primary: {
|
||||||
|
50: '#EFF6F6',
|
||||||
|
100: '#D8E8E8',
|
||||||
|
200: '#B1D1D1',
|
||||||
|
300: '#82B1B2',
|
||||||
|
400: '#558A8B',
|
||||||
|
500: PRIMARY,
|
||||||
|
600: '#1E4041',
|
||||||
|
700: '#173334',
|
||||||
|
800: '#102626',
|
||||||
|
900: '#0B1B1B',
|
||||||
|
},
|
||||||
gray: {
|
gray: {
|
||||||
50: '#F8FAFC',
|
50: '#F8FAFC',
|
||||||
100: '#F1F5F9',
|
100: '#F1F5F9',
|
||||||
@@ -38,25 +51,6 @@ const config: Config = {
|
|||||||
800: '#1E293B',
|
800: '#1E293B',
|
||||||
900: '#0F172A',
|
900: '#0F172A',
|
||||||
},
|
},
|
||||||
blue: {
|
|
||||||
50: '#EFF6FF',
|
|
||||||
100: '#DBEAFE',
|
|
||||||
200: '#BFDBFE',
|
|
||||||
400: '#60A5FA',
|
|
||||||
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',
|
400: '#F87171',
|
||||||
500: '#EF4444',
|
500: '#EF4444',
|
||||||
@@ -67,6 +61,13 @@ const config: Config = {
|
|||||||
400: '#4ADE80',
|
400: '#4ADE80',
|
||||||
500: '#22C55E',
|
500: '#22C55E',
|
||||||
600: '#16A34A',
|
600: '#16A34A',
|
||||||
|
700: '#15803D',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
400: '#FBBF24',
|
||||||
|
500: '#F59E0B',
|
||||||
|
600: '#D97706',
|
||||||
|
700: '#B45309',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
@@ -74,26 +75,17 @@ const config: Config = {
|
|||||||
'"Noto Sans SC"', '"PingFang SC"', '"Microsoft YaHei"',
|
'"Noto Sans SC"', '"PingFang SC"', '"Microsoft YaHei"',
|
||||||
'ui-sans-serif', 'system-ui', 'sans-serif',
|
'ui-sans-serif', 'system-ui', 'sans-serif',
|
||||||
],
|
],
|
||||||
display: [
|
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
'"Instrument Serif"', '"Noto Serif SC"', '"Source Han Serif"',
|
|
||||||
'"Songti SC"', 'SimSun', 'serif',
|
|
||||||
],
|
|
||||||
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'stagger-in': {
|
'fade-in-up': {
|
||||||
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
'0%': { opacity: '0', transform: 'translateY(6px)' },
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
|
||||||
},
|
|
||||||
'slide-down': {
|
|
||||||
'0%': { opacity: '0', transform: 'translateY(-16px)' },
|
|
||||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'stagger-in': 'stagger-in 0.3s ease-out both',
|
'fade-in-up': 'fade-in-up 0.25s ease-out both',
|
||||||
'slide-down': 'slide-down 0.25s ease-out both',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 408 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 408 KiB |
Reference in New Issue
Block a user