feat: 支持表格单元格快捷编辑

This commit is contained in:
2026-07-21 14:17:08 +08:00
parent fe0b71a0a9
commit a585fd42d5
17 changed files with 2197 additions and 303 deletions

View File

@@ -0,0 +1,277 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import './style.css';
export type EditableCellEditor =
| 'text'
| 'textarea'
| 'number'
| 'money'
| 'date'
| 'date-range'
| 'select'
| 'multi-select'
| 'tags';
export interface EditableCellOption {
label: React.ReactNode;
value: string | number | boolean;
}
export interface EditableCellProps<Value = unknown> {
value: Value;
children: React.ReactNode;
editor?: EditableCellEditor;
options?: EditableCellOption[];
permission?: string;
disabled?: boolean;
required?: boolean;
min?: number;
max?: number;
placeholder?: string;
formatValue?: (value: Value) => unknown;
parseValue?: (value: unknown) => Value;
onSave: (value: Value) => Promise<void>;
}
let activeCell: { id: string; save: () => Promise<boolean> } | null = null;
let replayingOutsideAction = false;
export function normalizeEditableValue(value: unknown, editor: EditableCellEditor) {
if (editor === 'date') return value ? dayjs(value as string) : null;
if (editor === 'date-range')
return Array.isArray(value) ? value.map((item) => dayjs(item as string)) : null;
if (editor === 'number' || editor === 'money') {
return value === null || value === undefined || value === '' ? null : Number(value);
}
if (editor === 'multi-select' || editor === 'tags') return Array.isArray(value) ? value : [];
return value ?? '';
}
export function serializeEditableValue(value: unknown, editor: EditableCellEditor) {
if (editor === 'date') return value ? (value as Dayjs).format('YYYY-MM-DD') : undefined;
if (editor === 'date-range')
return Array.isArray(value)
? value.map((item) => (item as Dayjs).format('YYYY-MM-DD'))
: undefined;
if (editor === 'number' || editor === 'money') {
return value === null || value === undefined || value === '' ? undefined : Number(value);
}
if (typeof value === 'string') return value.trim();
return value;
}
export function editableValuesEqual(left: unknown, right: unknown) {
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}
function isEditorOverlay(target: EventTarget | null) {
return (
target instanceof Element &&
!!target.closest('.ant-select-dropdown, .ant-picker-dropdown, .ant-tooltip, .ant-message')
);
}
const EditableCell = <Value,>({
value,
children,
editor = 'text',
options,
permission,
disabled,
required,
min,
max,
placeholder,
formatValue,
parseValue,
onSave,
}: EditableCellProps<Value>) => {
const { hasPermission } = usePermission();
const idRef = useRef(crypto.randomUUID());
const rootRef = useRef<HTMLDivElement>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<unknown>(() =>
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
);
const enabled = !disabled && (!permission || hasPermission(permission));
const original = useMemo(
() =>
serializeEditableValue(
normalizeEditableValue(formatValue ? formatValue(value) : value, editor),
editor,
),
[editor, formatValue, value],
);
useEffect(() => {
if (!editing) {
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
}
}, [editing, editor, formatValue, value]);
const cancel = useCallback(() => {
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
if (activeCell?.id === idRef.current) activeCell = null;
setEditing(false);
}, [editor, formatValue, value]);
const saveValue = useCallback(async (nextDraft: unknown) => {
if (saving) return false;
const serialized = serializeEditableValue(nextDraft, editor);
if (required && (serialized === '' || serialized === undefined || serialized === null)) {
message.error('该字段不能为空');
return false;
}
if (editableValuesEqual(serialized, original)) {
if (activeCell?.id === idRef.current) activeCell = null;
setEditing(false);
return true;
}
setSaving(true);
try {
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
if (activeCell?.id === idRef.current) activeCell = null;
setEditing(false);
return true;
} catch (error) {
message.error((error as { message?: string })?.message || '保存失败');
return false;
} finally {
setSaving(false);
}
}, [editor, onSave, original, parseValue, required, saving]);
const save = useCallback(() => saveValue(draft), [draft, saveValue]);
useEffect(() => {
const cellId = idRef.current;
if (editing && activeCell?.id === cellId) activeCell.save = save;
return () => {
if (activeCell?.id === cellId) activeCell = null;
};
}, [editing, save]);
useEffect(() => {
if (!editing) return;
const onPointerDown = (event: PointerEvent) => {
if (replayingOutsideAction) return;
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
const actionTarget =
event.target instanceof Element
? (event.target.closest(
'button, a, input, label, [role="button"], .ant-pagination-item, .ant-pagination-prev, .ant-pagination-next',
) as HTMLElement | null)
: null;
if (!actionTarget) {
void save();
return;
}
event.preventDefault();
event.stopPropagation();
void save().then((saved) => {
if (!saved) return;
replayingOutsideAction = true;
actionTarget.click();
queueMicrotask(() => {
replayingOutsideAction = false;
});
});
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
};
}, [editing, save]);
const beginEdit = async () => {
if (!enabled || saving) return;
if (activeCell && activeCell.id !== idRef.current) {
const saved = await activeCell.save();
if (!saved) return;
}
activeCell = { id: idRef.current, save };
setEditing(true);
};
const onKeyDown = async (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
cancel();
return;
}
if (event.key === 'Enter' && editor !== 'textarea') {
event.preventDefault();
await save();
return;
}
if (event.key === 'Tab') await save();
};
const commonProps = {
autoFocus: true,
value: draft as never,
placeholder,
disabled: saving,
onChange: (next: unknown) =>
setDraft(
next && typeof next === 'object' && 'target' in next
? (next as React.ChangeEvent<HTMLInputElement>).target.value
: next,
),
onKeyDown,
};
let control: React.ReactNode;
if (editor === 'select' || editor === 'multi-select' || editor === 'tags') {
control = (
<Select
{...commonProps}
onChange={(next) => {
setDraft(next);
if (editor === 'select') void saveValue(next);
}}
mode={editor === 'multi-select' ? 'multiple' : editor === 'tags' ? 'tags' : undefined}
options={options}
open
popupMatchSelectWidth={false}
popupClassName="editable-cell-dropdown"
/>
);
} else if (editor === 'date') {
control = <DatePicker {...commonProps} format="YYYY-MM-DD" />;
} else if (editor === 'date-range') {
const { placeholder: _placeholder, ...rangeProps } = commonProps;
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" />;
} else if (editor === 'number' || editor === 'money') {
control = (
<InputNumber {...commonProps} min={min} max={max} precision={editor === 'money' ? 2 : 0} />
);
} else if (editor === 'textarea') {
control = <Input.TextArea {...commonProps} autoSize={{ minRows: 1, maxRows: 4 }} />;
} else {
control = <Input {...commonProps} />;
}
return (
<div
ref={rootRef}
className={`editable-cell${enabled ? ' editable-cell--enabled' : ''}${editing ? ' editable-cell--editing' : ''}`}
onDoubleClick={() => void beginEdit()}
>
{editing ? (
<Spin spinning={saving}>{control}</Spin>
) : (
<Tooltip title={enabled ? '双击编辑' : undefined}>{children}</Tooltip>
)}
</div>
);
};
export default EditableCell;