fix(前端): 修复成员表头与加班导出
关键改动: - 成员管理表格改为卡片内滚动,并固定每个表头单元格 - 加班导出按当前月份筛选导出记录 - 增加成员表头与加班导出月份过滤测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -155,49 +155,51 @@ function MembersPageContent() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
|
||||
<div className="flex-1 overflow-hidden bg-[var(--bg)] px-5 py-4">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">暂无成员</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">姓名</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">用户名</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">角色</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">部门</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">企业邮箱</th>
|
||||
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMembers.map((m) => (
|
||||
<tr key={m.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{m.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-[var(--ink-soft)]">{m.username ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center rounded-md bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] font-medium text-[var(--ink-soft)]">{roleName(m.roleId)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{departments.find((d) => d.id === m.departmentId)?.name ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{m.email}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => { setEditingMember(m); setShowMemberModal(true); }} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">编辑</button>
|
||||
{!isSystemAdminMember(m) && (
|
||||
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||
)}
|
||||
{isSystemAdminMember(m) && (
|
||||
<span className="h-6 px-2 inline-flex items-center rounded text-[11px] font-medium text-amber-700 bg-amber-50">系统账号</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<div className="h-full overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="bg-[var(--bg-subtle)]">
|
||||
<tr className="bg-[var(--bg-subtle)]">
|
||||
<th className="sticky top-0 z-20 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">姓名</th>
|
||||
<th className="sticky top-0 z-20 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">用户名</th>
|
||||
<th className="sticky top-0 z-20 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">角色</th>
|
||||
<th className="sticky top-0 z-20 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">部门</th>
|
||||
<th className="sticky top-0 z-20 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">企业邮箱</th>
|
||||
<th className="sticky top-0 z-20 border-b border-[var(--line)] bg-[var(--bg-subtle)] px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMembers.map((m) => (
|
||||
<tr key={m.id} className="border-b border-[var(--line-soft)] last:border-0 hover:bg-[var(--bg-subtle)] transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-[var(--ink)]">{m.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-[var(--ink-soft)]">{m.username ?? '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center rounded-md bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] font-medium text-[var(--ink-soft)]">{roleName(m.roleId)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{departments.find((d) => d.id === m.departmentId)?.name ?? '-'}</td>
|
||||
<td className="px-4 py-3 text-[var(--ink-soft)]">{m.email}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => { setEditingMember(m); setShowMemberModal(true); }} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">编辑</button>
|
||||
{!isSystemAdminMember(m) && (
|
||||
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||
)}
|
||||
{isSystemAdminMember(m) && (
|
||||
<span className="h-6 px-2 inline-flex items-center rounded text-[11px] font-medium text-amber-700 bg-amber-50">系统账号</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||
import { calcDuration, filterOvertimeRecordsForViewer } from '@/lib/overtime';
|
||||
import { calcDuration, filterOvertimeRecordsForExportMonth, filterOvertimeRecordsForViewer } from '@/lib/overtime';
|
||||
import type { OvertimeRecord } from '@/lib/overtime';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { DictDrawer } from '@/components/requirement/DictDrawer';
|
||||
@@ -133,7 +133,9 @@ function OvertimePageContent() {
|
||||
|
||||
const handleExport = () => {
|
||||
const header = ['项目', '版本', '加班人', '开始时间', '结束时间', '时长(h)', '加班原因', '备注'];
|
||||
const rows = filtered.map((r) => [
|
||||
const exportRecords = filterOvertimeRecordsForExportMonth(visibleRecords, monthFilter)
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||
const rows = exportRecords.map((r) => [
|
||||
projectName(r.projectId),
|
||||
versionName(r.versionId),
|
||||
r.person,
|
||||
|
||||
28
apps/web/lib/member-page-header.test.ts
Normal file
28
apps/web/lib/member-page-header.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
test('members table pins each header cell above scrolling rows', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'app/admin/members/page.tsx'), 'utf8');
|
||||
const headerClassNames = Array.from(
|
||||
source.matchAll(/<th className="([^"]+)">/g),
|
||||
(match) => match[1],
|
||||
);
|
||||
|
||||
assert.equal(headerClassNames.length, 6);
|
||||
for (const className of headerClassNames) {
|
||||
assert.match(className, /\bsticky\b/);
|
||||
assert.match(className, /\btop-0\b/);
|
||||
assert.match(className, /\bz-20\b/);
|
||||
assert.match(className, /bg-\[var\(--bg-subtle\)\]/);
|
||||
}
|
||||
});
|
||||
|
||||
test('members table scrolls inside the table card instead of the padded page area', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'app/admin/members/page.tsx'), 'utf8');
|
||||
|
||||
assert.match(source, /className="flex-1 overflow-hidden bg-\[var\(--bg\)\] px-5 py-4"/);
|
||||
assert.match(source, /className="h-full overflow-hidden rounded-2xl border border-\[var\(--line\)\] bg-\[var\(--bg-card\)\] shadow-\[var\(--shadow-sm\)\]"/);
|
||||
assert.match(source, /className="h-full overflow-y-auto"/);
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
filterOvertimeRecordsForExportMonth,
|
||||
filterOvertimeRecordsForViewer,
|
||||
type OvertimeRecord,
|
||||
} from './overtime';
|
||||
import type { Department, Member, RoleItem } from './members';
|
||||
import type { AuthUser } from './auth-user';
|
||||
import type { Department, Member, RoleItem } from './members';
|
||||
|
||||
const departments: Department[] = [
|
||||
{ id: 'dept-tech', name: 'Tech', order: 1, createdAt: '2024-01-01' },
|
||||
@@ -28,6 +29,29 @@ const records: OvertimeRecord[] = [
|
||||
record('ot-pm', 'PM Carol'),
|
||||
];
|
||||
|
||||
const exportRecords: OvertimeRecord[] = [
|
||||
{
|
||||
id: 'july-1',
|
||||
projectId: 'project-1',
|
||||
person: 'Alice',
|
||||
startTime: '2026-07-01T19:00:00',
|
||||
endTime: '2026-07-01T21:00:00',
|
||||
duration: 2,
|
||||
reasonId: 'reason-1',
|
||||
createdAt: '2026-07-01',
|
||||
},
|
||||
{
|
||||
id: 'august-1',
|
||||
projectId: 'project-1',
|
||||
person: 'Bob',
|
||||
startTime: '2026-08-02T19:00:00',
|
||||
endTime: '2026-08-02T21:00:00',
|
||||
duration: 2,
|
||||
reasonId: 'reason-1',
|
||||
createdAt: '2026-08-02',
|
||||
},
|
||||
];
|
||||
|
||||
test('filterOvertimeRecordsForViewer limits non-admin viewers to their department tree', () => {
|
||||
const visible = filterOvertimeRecordsForViewer(records, {
|
||||
viewer: toAuthUser(members[0]),
|
||||
@@ -50,6 +74,18 @@ test('filterOvertimeRecordsForViewer lets wildcard admin view every department',
|
||||
assert.deepEqual(visible.map((item) => item.id), ['ot-front', 'ot-back', 'ot-pm']);
|
||||
});
|
||||
|
||||
test('filters export records by selected month', () => {
|
||||
const result = filterOvertimeRecordsForExportMonth(exportRecords, '2026-07');
|
||||
|
||||
assert.deepEqual(result.map((record) => record.id), ['july-1']);
|
||||
});
|
||||
|
||||
test('keeps all export records when all months are selected', () => {
|
||||
const result = filterOvertimeRecordsForExportMonth(exportRecords, '');
|
||||
|
||||
assert.deepEqual(result.map((record) => record.id), ['july-1', 'august-1']);
|
||||
});
|
||||
|
||||
function member(id: string, name: string, departmentId: string, roleId: string): Member {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -118,6 +118,14 @@ export function calcDuration(start: string, end: string): number {
|
||||
return Math.round(total * 10) / 10;
|
||||
}
|
||||
|
||||
export function filterOvertimeRecordsForExportMonth(
|
||||
records: OvertimeRecord[],
|
||||
monthFilter: string,
|
||||
): OvertimeRecord[] {
|
||||
if (!monthFilter) return [...records];
|
||||
return records.filter((record) => record.startTime.slice(0, 7) === monthFilter);
|
||||
}
|
||||
|
||||
function collectDepartmentTreeIds(departments: Department[], departmentId: string): Set<string> {
|
||||
const ids = new Set<string>([departmentId]);
|
||||
let changed = true;
|
||||
|
||||
Reference in New Issue
Block a user