feat(export): 新增活动时长总表(矩阵)+ 移除Logo
Deploy to Server / deploy (push) Canceled after 14m38s

- 后端 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:
Trae Assistant
2026-10-04 23:17:19 +08:00
parent 1a51a854a3
commit a84a8b19ac
4 changed files with 118 additions and 12 deletions
+4 -11
View File
@@ -14,20 +14,13 @@ export default function Brand({
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' },
sm: { title: 'text-sm font-semibold' },
md: { title: 'text-base font-bold' },
lg: { 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>
<div className={`inline-flex items-center ${className}`}>
{showTitle && !condensed && (
<span
className={`${variant === 'light' ? 'text-white' : 'text-gray-900'} ${sizes.title} leading-tight whitespace-nowrap`}
+78
View File
@@ -20,6 +20,7 @@ type Opts = {
include_hours: boolean;
include_user_totals: boolean;
include_user_activities: boolean;
include_activity_matrix: boolean;
};
const defaultOpts: Opts = {
@@ -28,6 +29,7 @@ const defaultOpts: Opts = {
include_hours: true,
include_user_totals: true,
include_user_activities: false,
include_activity_matrix: false,
};
type Category = {
@@ -80,6 +82,14 @@ const CATEGORIES: Category[] = [
desc: '每位部员参与过的所有活动明细(活动较多时文件较大)',
icon: <IconEye size={28} />,
},
{
key: 'include_activity_matrix',
tag: '06',
title: '活动时长总表(矩阵)',
subtitle: 'Activity Matrix',
desc: '行是部员(姓名+义工号),列是所有活动;多人同活动在同一列,展示每位部员的对应时长',
icon: <IconExport size={28} />,
},
];
export default function Export() {
@@ -204,6 +214,18 @@ export default function Export() {
});
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`);
};
@@ -350,6 +372,62 @@ export default function Export() {
</div>
</section>
) : 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>
+4
View File
@@ -94,4 +94,8 @@ export interface ExportResult {
hours?: any[];
user_totals?: UserTotal[];
user_activities?: UserActivities[];
activity_matrix?: {
activities: Array<{ id: number; title: string; start_time: string }>;
users: Array<Record<string, any>>;
};
}
+32 -1
View File
@@ -5,7 +5,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth';
const router = Router();
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 result: any = {};
@@ -65,6 +65,37 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
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);
});