import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DatePicker, Input, InputNumber, Select, Spin, Tooltip } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import equal from 'fast-deep-equal'; import { usePermission } from '../../hooks/usePermission'; import { useTimeout } from 'usehooks-ts'; import { useEditableCellStore } from '../../store/editableCell/editableCellStore'; import { message } from '../../ui/app-message'; import './style.css'; import { getErrorMessage } from '../../utils/error'; 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: 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; } 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 equal(left ?? null, 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, children, editor = 'text', options, permission, disabled, required, min, max, placeholder, formatValue, parseValue, onSave, }: EditableCellProps) => { const { hasPermission } = usePermission(); const idRef = useRef(crypto.randomUUID()); const rootRef = useRef(null); const touchStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null); const lastTouchTapRef = useRef<{ time: number; x: number; y: number } | null>(null); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const [draft, setDraft] = useState(() => normalizeEditableValue(formatValue ? formatValue(value) : value, editor), ); // 保存成功后短暂显示「撤销」入口:记录保存前的序列化旧值 const [undoMeta, setUndoMeta] = useState<{ serializedPrevious: unknown } | null>(null); // 撤销入口 6 秒后自动消失;useTimeout 在 undoMeta 置空/组件卸载时自动清理 useTimeout(() => setUndoMeta(null), undoMeta ? 6_000 : null); const enabled = !disabled && (!permission || hasPermission(permission)); const original = useMemo( () => serializeEditableValue( normalizeEditableValue(formatValue ? formatValue(value) : value, editor), editor, ), [editor, formatValue, value], ); const cancel = useCallback(() => { setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); useEditableCellStore.getState().clearIfActive(idRef.current); 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)) { useEditableCellStore.getState().clearIfActive(idRef.current); setEditing(false); return true; } setSaving(true); const previousValue = original; try { await onSave(parseValue ? parseValue(serialized) : (serialized as Value)); useEditableCellStore.getState().clearIfActive(idRef.current); setEditing(false); // 提供 6 秒内的撤销入口(把旧值再保存一次);useTimeout 负责到时自动清除 setUndoMeta({ serializedPrevious: previousValue }); return true; } catch (error) { message.error(getErrorMessage(error, '保存失败')); 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) useEditableCellStore.getState().updateActiveSave(cellId, save); return () => useEditableCellStore.getState().clearIfActive(cellId); }, [editing, save]); useEffect(() => { if (!editing) return; const onPointerDown = (event: PointerEvent) => { if (useEditableCellStore.getState().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; useEditableCellStore.getState().setReplayingOutsideAction(true); actionTarget.click(); queueMicrotask(() => { useEditableCellStore.getState().setReplayingOutsideAction(false); }); }); }; document.addEventListener('pointerdown', onPointerDown, true); return () => { document.removeEventListener('pointerdown', onPointerDown, true); }; }, [editing, save]); const beginEdit = async () => { if (!enabled || saving) return; const { activeCell } = useEditableCellStore.getState(); if (activeCell && activeCell.id !== idRef.current) { const saved = await activeCell.save(); if (!saved) return; } useEditableCellStore.getState().setActiveCell({ id: idRef.current, save }); // 重新进入编辑时清掉上一次的撤销入口 setUndoMeta(null); setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor)); setEditing(true); }; const handleUndo = async () => { if (!undoMeta) return; setUndoMeta(null); try { await onSave( parseValue ? parseValue(undoMeta.serializedPrevious) : (undoMeta.serializedPrevious as Value), ); message.success('已撤销修改'); } catch (error) { message.error(getErrorMessage(error, '撤销失败')); } }; const onPointerDown = (event: React.PointerEvent) => { if (event.pointerType !== 'touch' || editing) return; touchStartRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, }; }; const onPointerUp = (event: React.PointerEvent) => { const touchStart = touchStartRef.current; touchStartRef.current = null; if (!touchStart || event.pointerType !== 'touch' || event.pointerId !== touchStart.pointerId) { return; } const moved = Math.hypot(event.clientX - touchStart.x, event.clientY - touchStart.y); if (moved > 8) { lastTouchTapRef.current = null; return; } const now = Date.now(); const lastTap = lastTouchTapRef.current; const isDoubleTap = !!lastTap && now - lastTap.time <= 450 && Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) <= 24; lastTouchTapRef.current = isDoubleTap ? null : { time: now, x: event.clientX, y: event.clientY }; if (isDoubleTap) void beginEdit(); }; const onKeyDown = async (event: React.KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); cancel(); return; } if (event.key === 'Enter' && editor !== 'textarea') { // 这些编辑器会自己消费 Enter(确认/提交选中值),不重复触发单元格保存 if ( editor === 'select' || editor === 'multi-select' || editor === 'tags' || editor === 'date' || editor === 'date-range' ) { return; } 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).target.value : next, ), onKeyDown, }; let control: React.ReactNode; if (editor === 'select' || editor === 'multi-select' || editor === 'tags') { control = ( ; } return (
void beginEdit()} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={() => { touchStartRef.current = null; lastTouchTapRef.current = null; }} > {editing ? ( {control} ) : ( {children} {undoMeta ? ( ) : null} )}
); }; export default EditableCell;