` — carries the
+ // business context instead of an opaque `gantt-week`, and the timestamp
+ // keeps repeated exports from overwriting each other. Filesystem-hostile
+ // characters are stripped from the label.
+ const exportFileBase = React.useCallback(() => {
+ const base = (exportFileName ?? '').replace(/[\\/:*?"<>|\s]+/g, ' ').trim() || 'gantt';
+ const d = new Date();
+ const p = (n: number) => String(n).padStart(2, '0');
+ const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
+ return `${base}-${ts}`;
+ }, [exportFileName]);
+
const exportPng = React.useCallback(async () => {
const built = buildExportSvg();
if (!built) return;
const canvas = await rasterizeSvg(built.svg, built.W, built.H);
if (!canvas) return;
- canvas.toBlob((png) => { if (png) downloadBlob(png, `gantt-${viewMode}.png`); }, 'image/png');
- }, [buildExportSvg, viewMode]);
+ canvas.toBlob((png) => { if (png) downloadBlob(png, `${exportFileBase()}.png`); }, 'image/png');
+ }, [buildExportSvg, exportFileBase]);
const exportPdf = React.useCallback(async () => {
const built = buildExportSvg();
@@ -2869,8 +3072,8 @@ export function GanttView({
// JPEG keeps the embedded image small and embeds directly via DCTDecode.
const jpeg = dataUrlToBytes(canvas.toDataURL('image/jpeg', 0.92));
const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height);
- downloadBlob(pdf, `gantt-${viewMode}.pdf`);
- }, [buildExportSvg, viewMode]);
+ downloadBlob(pdf, `${exportFileBase()}.pdf`);
+ }, [buildExportSvg, exportFileBase]);
// Snapshot the current layout (granularity + zoom + list state), persist it
// under persistLayoutKey, and notify onLayoutChange. The persisted columnWidth
@@ -4788,6 +4991,68 @@ export function GanttView({
{/* 拖拽冲突 → 顺延确认 (Group 2). A centered modal lists how many tasks
would shift and offers to auto-reschedule (自动顺延) or keep the manual
placement (取消保留). */}
+ {/* 自动排程确认 — the toolbar wand computes first, then asks before the
+ bulk write; mirrors the conflict dialog's interaction contract. */}
+ {pendingAutoSchedule && (
+ setPendingAutoSchedule(null)}
+ >
+
e.stopPropagation()}
+ >
+
+ {t('gantt.autoScheduleDlg.title')}
+
+
+ {t('gantt.autoScheduleDlg.body').replace('{count}', String(pendingAutoSchedule.changes.length))}
+ {pendingAutoSchedule.skipped > 0 && (
+ <>
+ {' '}
+
+ {t('gantt.autoScheduleDlg.skipped').replace('{count}', String(pendingAutoSchedule.skipped))}
+
+ >
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* 无需排程 transient notice — every link already holds. */}
+ {autoScheduleClean && (
+
+ {t('gantt.autoScheduleDlg.none')}
+
+ )}
+
{pendingConflict && (
{
+ it('no timeZone → identity', () => {
+ const s = makeTzShift(undefined);
+ expect(s.delta).toBe(0);
+ const d = new Date('2026-07-20T00:00:00Z');
+ expect(s.to(d)).toBe(d);
+ expect(s.from(d)).toBe(d);
+ });
+
+ it('invalid IANA name → identity fallback (no throw)', () => {
+ const s = makeTzShift('Not/AZone');
+ expect(s.delta).toBe(0);
+ });
+
+ it('to/from round-trips to the exact original instant', () => {
+ const s = makeTzShift('Asia/Shanghai');
+ for (const iso of ['2026-07-20T00:00:00Z', '2026-01-05T13:37:11Z', '2026-12-31T23:59:59Z']) {
+ const d = new Date(iso);
+ expect(s.from(s.to(d)).getTime()).toBe(d.getTime());
+ }
+ });
+
+ it('display space reads the configured zone wall time', () => {
+ const s = makeTzShift('Asia/Shanghai');
+ // 2026-07-20T00:00Z = 08:00 Beijing. Its display-space Date must read
+ // 08:00 on the BROWSER-LOCAL clock (that is the whole trick).
+ const disp = s.to(new Date('2026-07-20T00:00:00Z'));
+ expect(disp.getHours()).toBe(8);
+ expect(disp.getDate()).toBe(20);
+ });
+
+ it('DST zone round-trips across both regimes', () => {
+ const s = makeTzShift('America/New_York');
+ for (const iso of ['2026-01-15T12:00:00Z', '2026-07-15T12:00:00Z']) {
+ const d = new Date(iso);
+ expect(s.from(s.to(d)).getTime()).toBe(d.getTime());
+ expect(s.to(d).getHours()).toBe(iso.startsWith('2026-01') ? 7 : 8);
+ }
+ });
+});
diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx
index 14e077365..09d6eb2a8 100644
--- a/packages/plugin-gantt/src/ObjectGantt.tsx
+++ b/packages/plugin-gantt/src/ObjectGantt.tsx
@@ -169,6 +169,21 @@ type GanttConfigEx = GanttConfig & {
* (unfiltered) task set while filtering only hides bars.
*/
autoZoomToFilter?: boolean;
+ /**
+ * Business time zone (业务时区), IANA name like 'Asia/Shanghai'. Renders the
+ * chart's calendar — shift bands, day columns, snapping, today line, date
+ * labels — in this zone's wall time for every viewer, instead of the
+ * browser's zone (which misplaces 班次 for viewers elsewhere). Persisted
+ * data stays real instants. Forwarded to {@link GanttView}.
+ */
+ timeZone?: string;
+ /**
+ * Base name for exported PNG/PDF files (导出文件名), e.g. the view's display
+ * label — the host's view schema often reaches this component stripped of
+ * `label`, so views declare it here. Falls back to the object schema label,
+ * then the object API name. A timestamp suffix is always appended.
+ */
+ exportFileName?: string;
/**
* Per-interaction switches (交互开关): `move` / `resize` / `progress` / `link`,
* each defaulting to true. Metadata-drivable so a view can e.g. allow bar
@@ -369,6 +384,8 @@ function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null {
autoZoomToFilter: schema.autoZoomToFilter,
timeSegments: schema.timeSegments,
interactions: schema.interactions,
+ exportFileName: schema.exportFileName,
+ timeZone: schema.timeZone,
};
return config;
}
@@ -1422,7 +1439,16 @@ export const ObjectGantt: React.FC = ({
defaultCollapsedDepth={ganttConfig?.defaultCollapsedDepth}
summaryExtent={ganttConfig?.summaryExtent}
interactions={ganttConfig?.interactions}
+ timeZone={ganttConfig?.timeZone}
onBeforeTaskUpdate={onBeforeTaskUpdate}
+ exportFileName={
+ // Explicit view config first (排班计划甘特图) — the host strips
+ // `label` off the schema it hands us — then the bound object's
+ // label, then its API name.
+ String(
+ ganttConfig?.exportFileName ?? (schema as any).label ?? objectSchema?.label ?? schema.objectName ?? ''
+ ) || undefined
+ }
inlineEdit
onRefresh={
// Only meaningful when there's a live source to re-read (object or
diff --git a/packages/plugin-gantt/src/scheduling.selfextent.test.ts b/packages/plugin-gantt/src/scheduling.selfextent.test.ts
new file mode 100644
index 000000000..2ac1326b7
--- /dev/null
+++ b/packages/plugin-gantt/src/scheduling.selfextent.test.ts
@@ -0,0 +1,103 @@
+/**
+ * computeProjectRescheduleDetailed — self-extent summaries, locked skipping,
+ * child cascade and shift-band snapping (自主日期汇总条参与顺延).
+ */
+import { describe, it, expect } from 'vitest';
+import { computeProjectReschedule, computeProjectRescheduleDetailed, type SchedulableTask } from './scheduling';
+
+const D = (s: string) => new Date(s);
+
+/** A(summary, own dates, children c1 locked + c2 free) ← fs — P(pred). */
+function makeTree(): SchedulableTask[] {
+ return [
+ { id: 'P', start: D('2024-06-01'), end: D('2024-06-10') },
+ {
+ id: 'A',
+ start: D('2024-06-05'),
+ end: D('2024-06-12'),
+ dependencies: ['P'],
+ hasOwnDates: true,
+ },
+ { id: 'c1', start: D('2024-06-05'), end: D('2024-06-07'), parent: 'A', locked: true },
+ { id: 'c2', start: D('2024-06-07'), end: D('2024-06-09'), parent: 'A' },
+ ];
+}
+
+describe('self-extent summary participation', () => {
+ it("default ('children') semantics: a violated summary stays put (back-compat)", () => {
+ const changes = computeProjectReschedule(makeTree());
+ expect(changes.find((c) => c.id === 'A')).toBeUndefined();
+ });
+
+ it("'self': a violated own-dates summary is pushed to its predecessor's end", () => {
+ const { changes } = computeProjectRescheduleDetailed(makeTree(), undefined, { summaryExtent: 'self' });
+ const a = changes.find((c) => c.id === 'A');
+ expect(a).toBeTruthy();
+ // FS: A.start >= P.end (06-10); duration 7d preserved.
+ expect(a!.start.toISOString()).toBe(D('2024-06-10').toISOString());
+ expect(a!.end.toISOString()).toBe(D('2024-06-17').toISOString());
+ });
+
+ it('unlocked descendants ride along by the same delta; locked ones stay', () => {
+ const { changes } = computeProjectRescheduleDetailed(makeTree(), undefined, { summaryExtent: 'self' });
+ // delta = +5 days.
+ const c2 = changes.find((c) => c.id === 'c2');
+ expect(c2).toBeTruthy();
+ expect(c2!.start.toISOString()).toBe(D('2024-06-12').toISOString());
+ expect(c2!.end.toISOString()).toBe(D('2024-06-14').toISOString());
+ expect(changes.find((c) => c.id === 'c1')).toBeUndefined();
+ });
+
+ it('a descendant already pushed further by its own links keeps the later position', () => {
+ const tasks = makeTree();
+ // Late external predecessor forces c2 to 06-20 — beyond the +5d cascade.
+ tasks.push({ id: 'X', start: D('2024-06-15'), end: D('2024-06-20') });
+ (tasks.find((t) => t.id === 'c2') as SchedulableTask).dependencies = ['X'];
+ const { changes } = computeProjectRescheduleDetailed(tasks, undefined, { summaryExtent: 'self' });
+ const c2 = changes.find((c) => c.id === 'c2');
+ expect(c2!.start.toISOString()).toBe(D('2024-06-20').toISOString());
+ });
+
+ it("a date-less grouping summary (hasOwnDates false) still never moves in 'self' mode", () => {
+ const tasks = makeTree();
+ (tasks.find((t) => t.id === 'A') as SchedulableTask).hasOwnDates = false;
+ const { changes } = computeProjectRescheduleDetailed(tasks, undefined, { summaryExtent: 'self' });
+ expect(changes.find((c) => c.id === 'A')).toBeUndefined();
+ });
+
+ it('a locked violated task is reported in skippedLocked and left in place', () => {
+ const tasks: SchedulableTask[] = [
+ { id: 'P', start: D('2024-06-01'), end: D('2024-06-10') },
+ { id: 'B', start: D('2024-06-05'), end: D('2024-06-08'), dependencies: ['P'], locked: true },
+ ];
+ const { changes, skippedLocked } = computeProjectRescheduleDetailed(tasks);
+ expect(changes).toHaveLength(0);
+ expect(skippedLocked).toEqual(['B']);
+ });
+
+ it('a satisfied locked task is NOT reported', () => {
+ const tasks: SchedulableTask[] = [
+ { id: 'P', start: D('2024-06-01'), end: D('2024-06-03') },
+ { id: 'B', start: D('2024-06-05'), end: D('2024-06-08'), dependencies: ['P'], locked: true },
+ ];
+ const { skippedLocked } = computeProjectRescheduleDetailed(tasks);
+ expect(skippedLocked).toHaveLength(0);
+ });
+
+ it('snapStart pushes a moved task onto the grid but never touches satisfied tasks', () => {
+ const tasks: SchedulableTask[] = [
+ { id: 'P', start: D('2024-06-01T00:00:00Z'), end: D('2024-06-10T17:00:00Z') },
+ { id: 'B', start: D('2024-06-05T08:00:00Z'), end: D('2024-06-08T08:00:00Z'), dependencies: ['P'] },
+ { id: 'C', start: D('2024-06-02T09:30:00Z'), end: D('2024-06-03T09:30:00Z') },
+ ];
+ // Snap to the next whole hour divisible by 12h.
+ const TWELVE_H = 12 * 3600 * 1000;
+ const snap = (ms: number) => Math.ceil(ms / TWELVE_H) * TWELVE_H;
+ const { changes } = computeProjectRescheduleDetailed(tasks, undefined, { snapStart: snap });
+ const b = changes.find((c) => c.id === 'B');
+ // Required 06-10T17:00 → snapped to 06-11T00:00.
+ expect(b!.start.toISOString()).toBe(D('2024-06-11T00:00:00Z').toISOString());
+ // C is satisfied: not moved, not snapped.
+ expect(changes.find((c) => c.id === 'C')).toBeUndefined();
+ });
+});
diff --git a/packages/plugin-gantt/src/scheduling.ts b/packages/plugin-gantt/src/scheduling.ts
index bf73c6f8a..ae0909206 100644
--- a/packages/plugin-gantt/src/scheduling.ts
+++ b/packages/plugin-gantt/src/scheduling.ts
@@ -113,6 +113,17 @@ export interface SchedulableTask {
dependencies?: Array;
parent?: string | number | null;
type?: string;
+ /**
+ * Row-level lock (仅查看). A locked task is never moved by the reschedule;
+ * when it VIOLATES a link it is reported in `skippedLocked` instead.
+ */
+ locked?: boolean;
+ /**
+ * Whether the task's own start/end are real data (mirrors
+ * GanttTask.hasOwnDates). Only consulted for summaries under
+ * `summaryExtent: 'self'` — a date-less grouping level still never moves.
+ */
+ hasOwnDates?: boolean;
}
interface Edge {
@@ -302,29 +313,79 @@ export interface RescheduleChange {
end: Date;
}
+export interface RescheduleOptions {
+ /**
+ * How summary (parent) tasks participate. `'children'` (default): their
+ * dates are derived rollups — they constrain successors but are never
+ * moved. `'self'`: a summary whose dates are its OWN (`hasOwnDates !==
+ * false`, not locked) shifts like a task, and its unlocked descendants
+ * ride along by the same delta (the group-drag semantics).
+ */
+ summaryExtent?: 'children' | 'self';
+ /**
+ * Snap a computed start instant onto a grid — e.g. shift-band boundaries
+ * (班次边界), so a pushed task never starts mid-band. Must return an
+ * instant `>=` the input (this is 顺延; snapping may only push later).
+ * Applied only to tasks that actually move, and only in non-calendar mode
+ * (a WorkingCalendar already owns the day-granularity snapping).
+ */
+ snapStart?: (ms: number) => number;
+}
+
+export interface RescheduleResult {
+ changes: RescheduleChange[];
+ /**
+ * Tasks that VIOLATE a link but stayed put because they are locked
+ * (仅查看). Lets the UI report "N skipped" honestly instead of implying
+ * the schedule is fully repaired.
+ */
+ skippedLocked: string[];
+}
+
/**
* Dependency-driven forward reschedule. Walks the graph in topological order
* and pushes each task as late as its predecessors require, preserving its
- * duration and never moving it earlier than its current start. Summary tasks
- * (parents) are treated as fixed rollup nodes — they constrain successors but
- * are not themselves moved. Returns only the tasks whose dates change.
+ * duration and never moving it earlier than its current start.
+ *
+ * Summary handling follows {@link RescheduleOptions.summaryExtent}: rollup
+ * summaries are fixed predecessor nodes; self-dated summaries move and carry
+ * their unlocked subtree (a descendant already pushed further by its own
+ * links keeps the later position — 顺延 only, so max wins). The cascade is a
+ * single forward pass: carried positions don't re-propagate through links,
+ * matching the function's minimal-repair pragmatism (no constraint solver).
*
* When a {@link WorkingCalendar} is supplied, each task's duration is measured
* in working days and rescheduled tasks are snapped to start (and finish) on
* working days only — weekends/holidays are stepped over rather than consumed.
*
- * Returns an empty array when the graph has a cycle (ambiguous ordering).
+ * Returns empty results when the graph has a cycle (ambiguous ordering).
*/
-export function computeProjectReschedule(tasks: SchedulableTask[], cal?: WorkingCalendar): RescheduleChange[] {
- if (!tasks.length) return [];
+export function computeProjectRescheduleDetailed(
+ tasks: SchedulableTask[],
+ cal?: WorkingCalendar,
+ opts?: RescheduleOptions,
+): RescheduleResult {
+ if (!tasks.length) return { changes: [], skippedLocked: [] };
const edges = buildEdges(tasks);
const allIds = tasks.map((t) => key(t.id));
const { order, hasCycle } = topoOrder(allIds, edges);
- if (hasCycle) return [];
+ if (hasCycle) return { changes: [], skippedLocked: [] };
const summaries = parentIds(tasks);
const byId = new Map();
for (const t of tasks) byId.set(key(t.id), t);
+ const byParent = new Map();
+ for (const t of tasks) {
+ if (t.parent == null || t.parent === '') continue;
+ const pid = key(t.parent);
+ const arr = byParent.get(pid) ?? [];
+ arr.push(t);
+ byParent.set(pid, arr);
+ }
+
+ const selfExtent = opts?.summaryExtent === 'self';
+ const movableSummary = (t: SchedulableTask) =>
+ selfExtent && t.hasOwnDates !== false && !t.locked;
const preds = new Map();
for (const id of allIds) preds.set(id, []);
@@ -338,6 +399,8 @@ export function computeProjectReschedule(tasks: SchedulableTask[], cal?: Working
endMs.set(key(t.id), t.end.getTime());
}
+ const skippedLocked: string[] = [];
+
for (const id of order) {
const t = byId.get(id);
if (!t) continue;
@@ -371,9 +434,18 @@ export function computeProjectReschedule(tasks: SchedulableTask[], cal?: Working
}
if (candidate > reqStart) reqStart = candidate;
}
- // Summaries are derived rollups: keep them where they are, only let them
- // act as predecessors. Everything else shifts to satisfy its links.
- if (summaries.has(id)) continue;
+ const wantsMove = reqStart > origStart;
+ // Rollup summaries stay fixed predecessors; self-dated summaries fall
+ // through and shift like tasks.
+ if (summaries.has(id) && !movableSummary(t)) continue;
+ // A locked task never moves — but a violated one is worth reporting.
+ if (t.locked) {
+ if (wantsMove) skippedLocked.push(id);
+ continue;
+ }
+ if (wantsMove && !cal && opts?.snapStart) {
+ reqStart = Math.max(reqStart, opts.snapStart(reqStart));
+ }
if (cal) {
const s = nextWorkingDay(reqStart, cal);
startMs.set(id, s);
@@ -384,19 +456,51 @@ export function computeProjectReschedule(tasks: SchedulableTask[], cal?: Working
}
}
+ // Cascade: a shifted self-dated summary carries its subtree along — the
+ // same semantics as dragging its bar — skipping locked descendants. A
+ // descendant already pushed further by its OWN links keeps the later
+ // position (max wins; this stays 顺延-only).
+ if (selfExtent) {
+ for (const t of tasks) {
+ const id = key(t.id);
+ if (!summaries.has(id) || !movableSummary(t)) continue;
+ const delta = startMs.get(id)! - t.start.getTime();
+ if (delta <= 0) continue;
+ const stack = [...(byParent.get(id) ?? [])];
+ while (stack.length) {
+ const d = stack.pop()!;
+ const did = key(d.id);
+ stack.push(...(byParent.get(did) ?? []));
+ if (d.locked) continue;
+ const candStart = d.start.getTime() + delta;
+ if (candStart > startMs.get(did)!) {
+ const dur = endMs.get(did)! - startMs.get(did)!;
+ startMs.set(did, candStart);
+ endMs.set(did, candStart + dur);
+ }
+ }
+ }
+ }
+
const changes: RescheduleChange[] = [];
for (const t of tasks) {
const id = key(t.id);
- if (summaries.has(id)) continue;
const ns = startMs.get(id)!;
const ne = endMs.get(id)!;
- // In calendar mode a task can keep its start yet have its finish snapped off
- // a weekend, so report end-only moves too.
+ // Untouched categories (locked rows, rollup summaries) keep their seeded
+ // values and drop out here naturally. In calendar mode a task can keep
+ // its start yet have its finish snapped off a weekend, so end-only moves
+ // are reported too.
if (ns !== t.start.getTime() || ne !== t.end.getTime()) {
changes.push({ id, start: new Date(ns), end: new Date(ne) });
}
}
- return changes;
+ return { changes, skippedLocked };
+}
+
+/** Back-compat wrapper: changes only, default (rollup-summary) semantics. */
+export function computeProjectReschedule(tasks: SchedulableTask[], cal?: WorkingCalendar): RescheduleChange[] {
+ return computeProjectRescheduleDetailed(tasks, cal).changes;
}
// ---------------------------------------------------------------------------
diff --git a/packages/plugin-gantt/src/useGanttTranslation.ts b/packages/plugin-gantt/src/useGanttTranslation.ts
index 69624f43f..ec9db7565 100644
--- a/packages/plugin-gantt/src/useGanttTranslation.ts
+++ b/packages/plugin-gantt/src/useGanttTranslation.ts
@@ -79,6 +79,12 @@ export const GANTT_DEFAULT_TRANSLATIONS: Record = {
'gantt.conflict.body': 'This move conflicts with dependency constraints. Auto-reschedule {count} affected task(s)?',
'gantt.conflict.confirm': 'Auto-reschedule',
'gantt.conflict.cancel': 'Keep as is',
+ 'gantt.autoScheduleDlg.title': 'Auto-schedule',
+ 'gantt.autoScheduleDlg.body': 'Shift {count} task(s) later to satisfy dependency links?',
+ 'gantt.autoScheduleDlg.skipped': '{count} locked task(s) also violate links and were skipped.',
+ 'gantt.autoScheduleDlg.confirm': 'Apply',
+ 'gantt.autoScheduleDlg.cancel': 'Cancel',
+ 'gantt.autoScheduleDlg.none': 'All dependencies satisfied — nothing to reschedule.',
'gantt.resource.header': 'Resource',
'gantt.resource.peak': 'Peak',
'gantt.resource.over': 'overloaded',