- 后端 POST /api/export 新增 include_activity_matrix:部员×活动矩阵行,列=所有活动(start_time DESC排),_total尾列 - 前端 Export 新增 06 卡片「活动时长总表(矩阵)」:部员+义工号 左固定列,活动列160px宽表头显示日期副标题,值>0高亮主色+浅底,总时长bg-primary-50 - Excel 新增「活动时长总表(矩阵)」Sheet:列名=活动标题,末列总时长 - 移除Brand组件所有img Logo渲染,纯标题,减小侧栏移动端头部视觉噪音 - 前后端 tsc --noEmit exit 0 + vite build exit 0;浏览器 E2E 验证矩阵张三/校园志愿宣传活动=2.00/总时长 2.00 h
This commit is contained in:
@@ -14,20 +14,13 @@ export default function Brand({
|
|||||||
className = '',
|
className = '',
|
||||||
}: BrandProps) {
|
}: BrandProps) {
|
||||||
const sizes = {
|
const sizes = {
|
||||||
sm: { logo: 'w-[28px] h-auto', title: 'text-sm font-semibold' },
|
sm: { title: 'text-sm font-semibold' },
|
||||||
md: { logo: 'w-[36px] h-auto', title: 'text-base font-bold' },
|
md: { title: 'text-base font-bold' },
|
||||||
lg: { logo: 'w-[44px] h-auto', title: 'text-xl font-bold tracking-tight' },
|
lg: { title: 'text-xl font-bold tracking-tight' },
|
||||||
}[size];
|
}[size];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`inline-flex items-center gap-2.5 ${className}`}>
|
<div className={`inline-flex items-center ${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 && (
|
{showTitle && !condensed && (
|
||||||
<span
|
<span
|
||||||
className={`${variant === 'light' ? 'text-white' : 'text-gray-900'} ${sizes.title} leading-tight whitespace-nowrap`}
|
className={`${variant === 'light' ? 'text-white' : 'text-gray-900'} ${sizes.title} leading-tight whitespace-nowrap`}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Opts = {
|
|||||||
include_hours: boolean;
|
include_hours: boolean;
|
||||||
include_user_totals: boolean;
|
include_user_totals: boolean;
|
||||||
include_user_activities: boolean;
|
include_user_activities: boolean;
|
||||||
|
include_activity_matrix: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultOpts: Opts = {
|
const defaultOpts: Opts = {
|
||||||
@@ -28,6 +29,7 @@ const defaultOpts: Opts = {
|
|||||||
include_hours: true,
|
include_hours: true,
|
||||||
include_user_totals: true,
|
include_user_totals: true,
|
||||||
include_user_activities: false,
|
include_user_activities: false,
|
||||||
|
include_activity_matrix: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Category = {
|
type Category = {
|
||||||
@@ -80,6 +82,14 @@ const CATEGORIES: Category[] = [
|
|||||||
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
|
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
|
||||||
icon: <IconEye size={28} />,
|
icon: <IconEye size={28} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'include_activity_matrix',
|
||||||
|
tag: '06',
|
||||||
|
title: '活动时长总表(矩阵)',
|
||||||
|
subtitle: 'Activity Matrix',
|
||||||
|
desc: '行是部员(姓名+义工号),列是所有活动;多人同活动在同一列,展示每位部员的对应时长',
|
||||||
|
icon: <IconExport size={28} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Export() {
|
export default function Export() {
|
||||||
@@ -204,6 +214,18 @@ export default function Export() {
|
|||||||
});
|
});
|
||||||
sheets.push({ name: '部员活动明细', data: flat });
|
sheets.push({ name: '部员活动明细', data: flat });
|
||||||
}
|
}
|
||||||
|
if (preview.activity_matrix?.activities?.length && preview.activity_matrix?.users?.length) {
|
||||||
|
const { activities, users } = preview.activity_matrix;
|
||||||
|
const data = users.map((u) => {
|
||||||
|
const row: any = { '部员': u.name, '义工号': u.volunteer_no || '-' };
|
||||||
|
for (const a of activities) {
|
||||||
|
row[a.title] = Number(u[String(a.id)] || 0).toFixed(2);
|
||||||
|
}
|
||||||
|
row['总时长'] = Number(u._total || 0).toFixed(2);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
sheets.push({ name: '活动时长总表(矩阵)', data });
|
||||||
|
}
|
||||||
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
exportToExcel(sheets, `义工数据-全部导出-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -350,6 +372,62 @@ export default function Export() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{preview.activity_matrix?.activities?.length ? (
|
||||||
|
<section className="card p-5">
|
||||||
|
<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-primary">06</span> 活动时长总表(矩阵)
|
||||||
|
<span className="chip ml-2">{preview.activity_matrix.users.length} 人 × {preview.activity_matrix.activities.length} 活动</span>
|
||||||
|
</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm border-collapse border border-gray-200 rounded-lg bg-white">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 border-b border-gray-200">
|
||||||
|
<th className="px-3 py-2.5 text-left font-semibold whitespace-nowrap border-r border-gray-200 text-gray-800 sticky left-0 bg-gray-100 z-10">部员</th>
|
||||||
|
<th className="px-3 py-2.5 text-left font-semibold whitespace-nowrap border-r border-gray-200 text-gray-800 sticky left-[96px] bg-gray-100 z-10">义工号</th>
|
||||||
|
{preview.activity_matrix.activities.map((a, i) => (
|
||||||
|
<th
|
||||||
|
key={a.id}
|
||||||
|
className={`px-3 py-2.5 text-center font-semibold whitespace-nowrap text-gray-800 ${i < preview.activity_matrix!.activities!.length - 1 ? 'border-r border-gray-200' : ''}`}
|
||||||
|
style={{ minWidth: 160 }}
|
||||||
|
title={`${a.title}\n${String(a.start_time || '').slice(0, 16)}`}
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{a.title}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-gray-500 font-normal">{String(a.start_time || '').slice(0, 10)}</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-2.5 text-center font-semibold whitespace-nowrap border-l border-gray-200 text-gray-800 bg-primary-50/60">总时长</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.activity_matrix.users.map((u, ri) => (
|
||||||
|
<tr key={u.id} className={`border-b border-gray-200 ${ri % 2 ? 'bg-gray-50' : 'bg-white'} hover:bg-primary-50 transition-colors`}>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-medium text-gray-800 sticky left-0 z-10" style={{ background: ri % 2 ? '#f9fafb' : '#fff' }}>{u.name}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap border-r border-gray-200 font-mono text-xs text-gray-600 sticky left-[96px] z-10" style={{ background: ri % 2 ? '#f9fafb' : '#fff' }}>{u.volunteer_no || '-'}</td>
|
||||||
|
{preview.activity_matrix!.activities!.map((a, i) => {
|
||||||
|
const v = Number(u[String(a.id)] || 0);
|
||||||
|
const cls = v > 0
|
||||||
|
? 'text-primary-700 font-semibold bg-primary-50/40'
|
||||||
|
: 'text-gray-400';
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={a.id}
|
||||||
|
className={`px-3 py-2 text-center whitespace-nowrap ${cls} ${i < preview.activity_matrix!.activities!.length - 1 ? 'border-r border-gray-200/70' : ''}`}
|
||||||
|
>
|
||||||
|
{v > 0 ? v.toFixed(2) : '—'}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap text-center border-l border-gray-200 font-semibold bg-primary-50/70 text-primary-700">
|
||||||
|
{Number(u._total || 0).toFixed(2)} h
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,4 +94,8 @@ export interface ExportResult {
|
|||||||
hours?: any[];
|
hours?: any[];
|
||||||
user_totals?: UserTotal[];
|
user_totals?: UserTotal[];
|
||||||
user_activities?: UserActivities[];
|
user_activities?: UserActivities[];
|
||||||
|
activity_matrix?: {
|
||||||
|
activities: Array<{ id: number; title: string; start_time: string }>;
|
||||||
|
users: Array<Record<string, any>>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/', authMiddleware, (req: AuthRequest, res) => {
|
router.post('/', authMiddleware, (req: AuthRequest, res) => {
|
||||||
const { include_users, include_activities, include_hours, include_user_totals, include_user_activities } = req.body || {};
|
const { include_users, include_activities, include_hours, include_user_totals, include_user_activities, include_activity_matrix } = req.body || {};
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result: any = {};
|
const result: any = {};
|
||||||
|
|
||||||
@@ -65,6 +65,37 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
|
|||||||
return { ...u, activities: acts };
|
return { ...u, activities: acts };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (include_activity_matrix) {
|
||||||
|
const activities = db.prepareCompat(`
|
||||||
|
SELECT id, title, start_time FROM activities ORDER BY start_time DESC, id DESC
|
||||||
|
`).all() as any[];
|
||||||
|
const users = db.prepareCompat(`
|
||||||
|
SELECT u.id, u.class, u.name, u.volunteer_no, g.name AS group_name
|
||||||
|
FROM users u LEFT JOIN work_groups g ON u.group_id = g.id
|
||||||
|
ORDER BY u.class, u.name
|
||||||
|
`).all() as any[];
|
||||||
|
const hoursRows = db.prepareCompat(`
|
||||||
|
SELECT user_id, activity_id, hours FROM hours
|
||||||
|
`).all() as any[];
|
||||||
|
const matrix: Record<string, number> = {};
|
||||||
|
for (const r of hoursRows) {
|
||||||
|
const key = `${r.user_id}|${r.activity_id}`;
|
||||||
|
matrix[key] = (matrix[key] || 0) + Number(r.hours || 0);
|
||||||
|
}
|
||||||
|
result.activity_matrix = {
|
||||||
|
activities,
|
||||||
|
users: users.map((u) => {
|
||||||
|
const row: Record<string, any> = { id: u.id, class: u.class, name: u.name, volunteer_no: u.volunteer_no, group_name: u.group_name };
|
||||||
|
for (const a of activities) {
|
||||||
|
row[String(a.id)] = Number((matrix[`${u.id}|${a.id}`] || 0).toFixed(2));
|
||||||
|
}
|
||||||
|
row._total = Number(
|
||||||
|
activities.reduce((s: number, a: any) => s + (Number(row[String(a.id)]) || 0), 0).toFixed(2)
|
||||||
|
);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user