- 后端 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 = '',
|
||||
}: 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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user