Skip to content

Commit 7ba9277

Browse files
authored
feat(gantt): 导航 + 年刻度 + 保存布局 + 导出 PDF (#1706)
第三组: - 年刻度:新增 year 粒度,时间轴一列一年,上方带「20XXs」十年分组带; ResourceWorkload 同步支持 year 列宽与标签。 - 导航:工具栏新增 本周 / 本月 跳转按钮(配合已有 今天),横向滚动时间轴到 当前周 / 月 起点。 - 保存布局:新增 persistLayoutKey / onLayoutChange,工具栏「保存布局」按钮把 当前粒度 + 缩放 + 任务列折叠快照写入 localStorage(key gantt-layout:<对象>:<视图>), 下次加载自动恢复;显式 viewMode prop 优先。ObjectGantt 默认按数据对象派生 key,可用 persistLayout:false 关闭。 - 导出 PDF:将整图 SVG 栅格化为 JPEG,内嵌进零依赖单页 PDF(DCTDecode), 与导出 PNG 并列;buildExportSvg 复用于两种导出。 测试:新增 GanttView.layout.test.tsx(12 例)+ ObjectGantt.test.tsx(持久化 key 接线 2 例),共 173 通过;tsc 干净。浏览器留痕 scripts/verify-export-layout.mjs (7/7 通过),截图 45-48。文档与 skill 同步更新。
1 parent fd9e7f2 commit 7ba9277

14 files changed

Lines changed: 608 additions & 49 deletions

packages/plugin-gantt/demo/main.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ const GANTT_ZH = {
3232
jumpToToday: '跳到今天', today: '今天', showTaskList: '显示任务列表', hideTaskList: '隐藏任务列表',
3333
viewMode: '时间粒度', enterFullscreen: '进入全屏', exitFullscreen: '退出全屏',
3434
criticalPath: '高亮关键路径', autoSchedule: '自动排程依赖', exportPng: '导出 PNG',
35+
exportPdf: '导出 PDF', saveLayout: '保存布局',
36+
thisWeek: '本周', thisMonth: '本月',
3537
undo: '撤销', redo: '重做',
3638
},
37-
viewMode: { day: '日', week: '周', month: '月', quarter: '季' },
39+
viewMode: { day: '日', week: '周', month: '月', quarter: '季', year: '年' },
3840
row: { expand: '展开', collapse: '折叠' },
3941
aria: { taskList: '任务列表' },
4042
tooltip: { days: '天' },
@@ -362,7 +364,9 @@ function App() {
362364
) : (
363365
<GanttView
364366
tasks={tasks}
365-
viewMode={(params.get('mode') as GanttViewMode) || 'day'}
367+
// Only force the granularity when ?mode= is given; otherwise let a
368+
// persisted layout (保存布局) restore it on reload.
369+
viewMode={params.get('mode') ? (params.get('mode') as GanttViewMode) : undefined}
366370
markers={markers}
367371
autoSchedule
368372
rescheduleOnConflict
@@ -372,6 +376,8 @@ function App() {
372376
readOnly={readOnly}
373377
groupBy={groupBy}
374378
ungroupedLabel="未分组"
379+
persistLayoutKey="demo-project"
380+
onLayoutChange={(l) => console.log('[gantt-demo] layout saved', l)}
375381
inlineEdit
376382
onTaskClick={(t) => console.log('[gantt-demo] click', t.id)}
377383
onTaskUpdate={(t, changes) => patch(t.id, changes)}
74.1 KB
Loading
77.3 KB
Loading
77.1 KB
Loading
77.5 KB
Loading

packages/plugin-gantt/docs/verification/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,3 +526,30 @@ The script asserts (5/5 checks passed):
526526
topological forward pass (FS/SS/FF/SF aware, summaries fixed), pushing t4 back
527527
to satisfy the link.
528528
- ![Auto-rescheduled](44-conflict-rescheduled.png)
529+
530+
## Navigation + year scale + save layout + PNG/PDF export — 导航 + 年刻度 + 保存布局 + 导出
531+
532+
`scripts/verify-export-layout.mjs` drives the `?lang=zh` project fixture and
533+
exercises the Group 3 toolbar additions. Covered by unit tests in
534+
[`GanttView.layout.test.tsx`](../../src/GanttView.layout.test.tsx) (12 cases)
535+
and [`ObjectGantt.test.tsx`](../../src/ObjectGantt.test.tsx) (persistLayoutKey
536+
wiring).
537+
538+
The script asserts (7/7 checks passed):
539+
540+
- **年刻度** — a new 年 granularity button widens the timeline to one column
541+
per year, with a `2020s` decade band above each year header.
542+
- ![Year granularity](45-year-granularity.png)
543+
- **导航** — 本周 / 本月 buttons scroll the timeline to the start of the current
544+
week / month (alongside the existing 今天 jump).
545+
- ![Navigation](46-navigation.png)
546+
- **保存布局** — the 保存布局 button snapshots the current granularity + zoom +
547+
list-collapse to `localStorage` (key `gantt-layout:<object>:<view>`) and fires
548+
`onLayoutChange`; the button briefly highlights to confirm.
549+
- ![Save layout](47-save-layout.png)
550+
- **持久化** — reloading without a `?mode=` override restores the saved 月
551+
granularity from `localStorage`.
552+
- ![Layout restored](48-layout-restored.png)
553+
- **导出 PNG / PDF** — both export buttons download real files: a valid PNG
554+
(`‰PNG` magic bytes) and a dependency-free single-page PDF (`%PDF-` header)
555+
embedding the rasterized chart as a JPEG via `DCTDecode`.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* 导航 + 年刻度 + 保存布局 + 导出 PNG/PDF verification (Group 3).
3+
*
4+
* Drives the demo (?lang=zh) project fixture and asserts:
5+
* 1. the 年 (year) granularity button switches the timeline (年刻度),
6+
* 2. 本周 / 本月 navigation buttons scroll the timeline,
7+
* 3. 保存布局 persists granularity to localStorage and survives a reload,
8+
* 4. 导出 PNG / 导出 PDF download files with the right extensions/magic bytes.
9+
* Persists screenshots 45-48 under docs/verification/.
10+
*
11+
* GANTT_DEMO_URL=http://localhost:5200 node packages/plugin-gantt/scripts/verify-export-layout.mjs
12+
*/
13+
import { chromium } from 'playwright';
14+
import { fileURLToPath } from 'node:url';
15+
import path from 'node:path';
16+
import fs from 'node:fs';
17+
import os from 'node:os';
18+
19+
const EXEC =
20+
'/Users/baozhoutao/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-arm64/chrome-headless-shell';
21+
const BASE = process.env.GANTT_DEMO_URL || 'http://localhost:5199';
22+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
23+
const OUT = path.resolve(__dirname, '../docs/verification');
24+
const DL = fs.mkdtempSync(path.join(os.tmpdir(), 'gantt-dl-'));
25+
26+
const browser = await chromium.launch({ executablePath: EXEC });
27+
const ctx = await browser.newContext({ viewport: { width: 1500, height: 900 }, acceptDownloads: true });
28+
const page = await ctx.newPage();
29+
const fails = [];
30+
const ok = (cond, msg, detail = '') => {
31+
if (!cond) fails.push(msg);
32+
console.log(`${cond ? '✓' : '✗'} ${msg}${detail ? ` — ${detail}` : ''}`);
33+
};
34+
const pressed = (testid) =>
35+
page.$eval(`[data-testid="${testid}"]`, (el) => el.getAttribute('aria-pressed')).catch(() => null);
36+
37+
const saveDownload = async (triggerSel) => {
38+
const [download] = await Promise.all([
39+
page.waitForEvent('download', { timeout: 8000 }),
40+
page.click(triggerSel),
41+
]);
42+
const name = download.suggestedFilename();
43+
const dest = path.join(DL, name);
44+
await download.saveAs(dest);
45+
return { name, bytes: fs.readFileSync(dest) };
46+
};
47+
48+
try {
49+
await page.goto(`${BASE}?lang=zh`, { waitUntil: 'networkidle' });
50+
await page.waitForSelector('[data-testid="gantt-view-mode-year"]', { timeout: 8000 });
51+
52+
// 1) 年刻度 — switch to the year granularity.
53+
await page.click('[data-testid="gantt-view-mode-year"]');
54+
await page.waitForTimeout(300);
55+
ok((await pressed('gantt-view-mode-year')) === 'true', '年 granularity button activates (年刻度)');
56+
await page.screenshot({ path: path.join(OUT, '45-year-granularity.png') });
57+
58+
// back to a finer mode for the nav test
59+
await page.click('[data-testid="gantt-view-mode-month"]');
60+
await page.waitForTimeout(200);
61+
62+
// 2) Navigation — 本周 / 本月 scroll the timeline.
63+
const scrollOf = () =>
64+
page.$eval('[data-testid="gantt-timeline"]', (el) => el.scrollLeft).catch(() => null);
65+
await page.click('[data-testid="gantt-jump-month"]');
66+
await page.waitForTimeout(200);
67+
const afterMonth = await scrollOf();
68+
ok(afterMonth !== null && Number.isFinite(afterMonth), '本月 navigation scrolls the timeline', `scrollLeft=${afterMonth}`);
69+
await page.click('[data-testid="gantt-jump-week"]');
70+
await page.waitForTimeout(200);
71+
ok(await page.$('[data-testid="gantt-jump-week"]'), '本周 navigation button present & clickable');
72+
await page.screenshot({ path: path.join(OUT, '46-navigation.png') });
73+
74+
// 3) 保存布局 — set month, save, reload, expect month restored.
75+
await page.click('[data-testid="gantt-view-mode-month"]');
76+
await page.waitForTimeout(150);
77+
await page.click('[data-testid="gantt-save-layout"]');
78+
await page.waitForTimeout(150);
79+
ok((await pressed('gantt-save-layout')) === 'true', '保存布局 button reflects a save (aria-pressed)');
80+
await page.screenshot({ path: path.join(OUT, '47-save-layout.png') });
81+
82+
// Reload WITHOUT a ?mode= override so the persisted layout wins.
83+
await page.goto(`${BASE}?lang=zh`, { waitUntil: 'networkidle' });
84+
await page.waitForSelector('[data-testid="gantt-view-mode-month"]', { timeout: 8000 });
85+
await page.waitForTimeout(300);
86+
ok((await pressed('gantt-view-mode-month')) === 'true', '保存布局 restores 月 granularity after reload (持久化)');
87+
await page.screenshot({ path: path.join(OUT, '48-layout-restored.png') });
88+
89+
// 4) 导出 PNG / PDF — verify real downloads with correct magic bytes.
90+
const png = await saveDownload('[data-testid="gantt-export-png"]');
91+
const pngMagic = png.bytes[0] === 0x89 && png.bytes[1] === 0x50 && png.bytes[2] === 0x4e && png.bytes[3] === 0x47;
92+
ok(png.name.endsWith('.png') && pngMagic, '导出 PNG downloads a valid PNG', `${png.name} ${png.bytes.length}B`);
93+
94+
const pdf = await saveDownload('[data-testid="gantt-export-pdf"]');
95+
const head = pdf.bytes.subarray(0, 5).toString('latin1');
96+
ok(pdf.name.endsWith('.pdf') && head === '%PDF-', '导出 PDF downloads a valid PDF', `${pdf.name} ${pdf.bytes.length}B head=${head}`);
97+
98+
console.log(`\nscreenshots → ${OUT} (45–48)`);
99+
} catch (err) {
100+
console.error(err);
101+
fails.push(String(err));
102+
} finally {
103+
await browser.close();
104+
fs.rmSync(DL, { recursive: true, force: true });
105+
}
106+
107+
if (fails.length) { console.error(`\n${fails.length} check(s) failed`); process.exit(1); }
108+
console.log('\nall checks passed');
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Group 3 tests: navigation buttons, year granularity, layout persistence
3+
* (保存布局), and the PNG / PDF export toolbar buttons.
4+
*
5+
* Conventions match the other interaction tests: innerWidth=1280 →
6+
* columnWidth 60, rowHeight 40.
7+
*/
8+
import React from 'react';
9+
import { render, fireEvent, act } from '@testing-library/react';
10+
import { describe, it, expect, beforeEach, vi } from 'vitest';
11+
import { GanttView, type GanttTask, type GanttLayout } from './GanttView';
12+
13+
beforeEach(() => {
14+
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
15+
window.localStorage.clear();
16+
});
17+
18+
function makeTask(id: string, start: string, end: string, extra: Partial<GanttTask> = {}): GanttTask {
19+
return { id, title: `Task ${id}`, start: new Date(start), end: new Date(end), progress: 0, ...extra };
20+
}
21+
22+
const TASKS = () => [
23+
makeTask('a', '2024-06-03T00:00:00.000Z', '2024-06-13T00:00:00.000Z', { progress: 50 }),
24+
makeTask('b', '2024-06-17T00:00:00.000Z', '2024-06-21T00:00:00.000Z'),
25+
];
26+
27+
function renderView(props: Partial<React.ComponentProps<typeof GanttView>> = {}) {
28+
return render(
29+
<div style={{ width: 1280, height: 600 }}>
30+
<GanttView
31+
tasks={TASKS()}
32+
startDate={new Date('2024-06-01T00:00:00.000Z')}
33+
endDate={new Date('2024-12-30T00:00:00.000Z')}
34+
{...props}
35+
/>
36+
</div>
37+
);
38+
}
39+
40+
describe('GanttView navigation buttons (导航)', () => {
41+
it('renders jump-to-today / this-week / this-month controls', () => {
42+
const { getByTestId } = renderView();
43+
expect(getByTestId('gantt-jump-today')).toBeTruthy();
44+
expect(getByTestId('gantt-jump-week')).toBeTruthy();
45+
expect(getByTestId('gantt-jump-month')).toBeTruthy();
46+
});
47+
48+
it('this-week / this-month scroll the timeline horizontally', () => {
49+
const { getByTestId } = renderView();
50+
const timeline = getByTestId('gantt-timeline') as HTMLElement;
51+
// jsdom has no layout, so scrollLeft is a plain settable number; assert the
52+
// handlers run without throwing and leave a finite scrollLeft.
53+
act(() => { fireEvent.click(getByTestId('gantt-jump-week')); });
54+
expect(Number.isFinite(timeline.scrollLeft)).toBe(true);
55+
act(() => { fireEvent.click(getByTestId('gantt-jump-month')); });
56+
expect(Number.isFinite(timeline.scrollLeft)).toBe(true);
57+
});
58+
});
59+
60+
describe('GanttView year granularity (年刻度)', () => {
61+
it('exposes a year view-mode button and switches to it', () => {
62+
const { getByTestId } = renderView();
63+
const yearBtn = getByTestId('gantt-view-mode-year');
64+
expect(yearBtn).toBeTruthy();
65+
act(() => { fireEvent.click(yearBtn); });
66+
expect(yearBtn.getAttribute('aria-pressed')).toBe('true');
67+
});
68+
69+
it('seeds the year granularity from the viewMode prop', () => {
70+
const { getByTestId } = renderView({ viewMode: 'year' });
71+
expect(getByTestId('gantt-view-mode-year').getAttribute('aria-pressed')).toBe('true');
72+
});
73+
});
74+
75+
describe('GanttView export buttons (导出 PNG / PDF)', () => {
76+
it('renders both the PNG and PDF export buttons', () => {
77+
const { getByTestId } = renderView();
78+
expect(getByTestId('gantt-export-png')).toBeTruthy();
79+
expect(getByTestId('gantt-export-pdf')).toBeTruthy();
80+
});
81+
});
82+
83+
describe('GanttView save layout (保存布局)', () => {
84+
it('hides the save-layout button without persistLayoutKey/onLayoutChange', () => {
85+
const { queryByTestId } = renderView();
86+
expect(queryByTestId('gantt-save-layout')).toBeNull();
87+
});
88+
89+
it('shows the save-layout button when onLayoutChange is set', () => {
90+
const { getByTestId } = renderView({ onLayoutChange: () => {} });
91+
expect(getByTestId('gantt-save-layout')).toBeTruthy();
92+
});
93+
94+
it('persists the current layout to localStorage under the key', () => {
95+
const { getByTestId } = renderView({ persistLayoutKey: 'proj1' });
96+
act(() => { fireEvent.click(getByTestId('gantt-view-mode-month')); });
97+
act(() => { fireEvent.click(getByTestId('gantt-save-layout')); });
98+
const raw = window.localStorage.getItem('gantt-layout:proj1');
99+
expect(raw).toBeTruthy();
100+
const saved = JSON.parse(raw!) as GanttLayout;
101+
expect(saved.viewMode).toBe('month');
102+
expect(saved.taskListCollapsed).toBe(false);
103+
});
104+
105+
it('calls onLayoutChange with the snapshot on save', () => {
106+
const onLayoutChange = vi.fn();
107+
const { getByTestId } = renderView({ onLayoutChange });
108+
act(() => { fireEvent.click(getByTestId('gantt-view-mode-quarter')); });
109+
act(() => { fireEvent.click(getByTestId('gantt-save-layout')); });
110+
expect(onLayoutChange).toHaveBeenCalledTimes(1);
111+
expect(onLayoutChange.mock.calls[0][0].viewMode).toBe('quarter');
112+
});
113+
114+
it('restores a persisted granularity on mount', () => {
115+
window.localStorage.setItem(
116+
'gantt-layout:proj2',
117+
JSON.stringify({ viewMode: 'month', columnWidth: null, taskListCollapsed: false } satisfies GanttLayout)
118+
);
119+
const { getByTestId } = renderView({ persistLayoutKey: 'proj2' });
120+
expect(getByTestId('gantt-view-mode-month').getAttribute('aria-pressed')).toBe('true');
121+
});
122+
123+
it('lets the viewMode prop win over a persisted granularity', () => {
124+
window.localStorage.setItem(
125+
'gantt-layout:proj3',
126+
JSON.stringify({ viewMode: 'month', columnWidth: null, taskListCollapsed: false } satisfies GanttLayout)
127+
);
128+
const { getByTestId } = renderView({ persistLayoutKey: 'proj3', viewMode: 'week' });
129+
expect(getByTestId('gantt-view-mode-week').getAttribute('aria-pressed')).toBe('true');
130+
expect(getByTestId('gantt-view-mode-month').getAttribute('aria-pressed')).toBe('false');
131+
});
132+
133+
it('ignores malformed persisted layout JSON', () => {
134+
window.localStorage.setItem('gantt-layout:proj4', '{not valid json');
135+
const { getByTestId } = renderView({ persistLayoutKey: 'proj4' });
136+
// Falls back to the default 'day' granularity without throwing.
137+
expect(getByTestId('gantt-view-mode-day').getAttribute('aria-pressed')).toBe('true');
138+
});
139+
});

0 commit comments

Comments
 (0)