refactor(admin): EditableCell 跨单元格协调状态迁入 zustand,替代模块级单例并补单测

This commit is contained in:
2026-08-08 17:04:42 +08:00
parent 260a7517d2
commit 9cc9ed09df
3 changed files with 117 additions and 14 deletions

View File

@@ -3,6 +3,7 @@ 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 { useEditableCellStore } from '../../store/editableCell/editableCellStore';
import { message } from '../../ui/app-message';
import './style.css';
import { getErrorMessage } from '../../utils/error';
@@ -39,9 +40,6 @@ export interface EditableCellProps<Value = unknown> {
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')
@@ -125,7 +123,7 @@ const EditableCell = <Value,>({
const cancel = useCallback(() => {
setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));
if (activeCell?.id === idRef.current) activeCell = null;
useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false);
}, [editor, formatValue, value]);
@@ -138,7 +136,7 @@ const EditableCell = <Value,>({
return false;
}
if (editableValuesEqual(serialized, original)) {
if (activeCell?.id === idRef.current) activeCell = null;
useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false);
return true;
}
@@ -146,7 +144,7 @@ const EditableCell = <Value,>({
const previousValue = original;
try {
await onSave(parseValue ? parseValue(serialized) : (serialized as Value));
if (activeCell?.id === idRef.current) activeCell = null;
useEditableCellStore.getState().clearIfActive(idRef.current);
setEditing(false);
// 提供 6 秒内的撤销入口(把旧值再保存一次)
setUndoMeta({ serializedPrevious: previousValue });
@@ -167,16 +165,14 @@ const EditableCell = <Value,>({
useEffect(() => {
const cellId = idRef.current;
if (editing && activeCell?.id === cellId) activeCell.save = save;
return () => {
if (activeCell?.id === cellId) activeCell = null;
};
if (editing) useEditableCellStore.getState().updateActiveSave(cellId, save);
return () => useEditableCellStore.getState().clearIfActive(cellId);
}, [editing, save]);
useEffect(() => {
if (!editing) return;
const onPointerDown = (event: PointerEvent) => {
if (replayingOutsideAction) return;
if (useEditableCellStore.getState().replayingOutsideAction) return;
if (rootRef.current?.contains(event.target as Node) || isEditorOverlay(event.target)) return;
const actionTarget =
event.target instanceof Element
@@ -192,10 +188,10 @@ const EditableCell = <Value,>({
event.stopPropagation();
void save().then((saved) => {
if (!saved) return;
replayingOutsideAction = true;
useEditableCellStore.getState().setReplayingOutsideAction(true);
actionTarget.click();
queueMicrotask(() => {
replayingOutsideAction = false;
useEditableCellStore.getState().setReplayingOutsideAction(false);
});
});
};
@@ -207,11 +203,12 @@ const EditableCell = <Value,>({
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;
}
activeCell = { id: idRef.current, save };
useEditableCellStore.getState().setActiveCell({ id: idRef.current, save });
// 重新进入编辑时清掉上一次的撤销入口
window.clearTimeout(undoTimerRef.current);
setUndoMeta(null);

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useEditableCellStore } from './editableCellStore';
const { getState } = useEditableCellStore;
describe('editableCellStore', () => {
beforeEach(() => getState().resetEditableCell());
it('setActiveCell / clearIfActive 只清理匹配 id 的激活单元格', () => {
const save = async () => true;
getState().setActiveCell({ id: 'a', save });
expect(getState().activeCell?.id).toBe('a');
getState().clearIfActive('b');
expect(getState().activeCell?.id).toBe('a');
getState().clearIfActive('a');
expect(getState().activeCell).toBeNull();
});
it('updateActiveSave 只更新最新激活单元格的 save 闭包', () => {
const s1 = async () => true;
const s2 = async () => false;
getState().setActiveCell({ id: 'a', save: s1 });
getState().updateActiveSave('a', s2);
expect(getState().activeCell?.save).toBe(s2);
// 非激活 id 的更新被忽略
getState().updateActiveSave('b', s1);
expect(getState().activeCell?.save).toBe(s2);
});
it('replayingOutsideAction 可置位与复位', () => {
expect(getState().replayingOutsideAction).toBe(false);
getState().setReplayingOutsideAction(true);
expect(getState().replayingOutsideAction).toBe(true);
getState().setReplayingOutsideAction(false);
expect(getState().replayingOutsideAction).toBe(false);
});
it('resetEditableCell 清空全部协调状态', () => {
getState().setActiveCell({ id: 'a', save: async () => true });
getState().setReplayingOutsideAction(true);
getState().resetEditableCell();
expect(getState().activeCell).toBeNull();
expect(getState().replayingOutsideAction).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* EditableCell 跨单元格协调状态。
*
* 原实现为模块级单例activeCell / replayingOutsideActionHMR、路由
* 卸载与测试之间不会重置;迁入 zustand 后行为一致且可重置、可测试。
* 所有读写都通过 getState() 命令式完成,不订阅渲染,避免无谓重渲染。
*/
export interface EditableCellSession {
id: string;
save: () => Promise<boolean>;
}
interface EditableCellStore {
/** 当前处于编辑态的单元格(全局唯一) */
activeCell: EditableCellSession | null;
/** 外部点击回放期间置位,抑制 document 级 pointerdown 的二次保存 */
replayingOutsideAction: boolean;
setActiveCell: (session: EditableCellSession | null) => void;
/** 编辑中更新 save 闭包(仅当仍是最新激活单元格时生效) */
updateActiveSave: (id: string, save: () => Promise<boolean>) => void;
/** 若指定 id 仍是最新激活单元格则清空 */
clearIfActive: (id: string) => void;
setReplayingOutsideAction: (value: boolean) => void;
resetEditableCell: () => void;
}
export const useEditableCellStore = create<EditableCellStore>()(
devtools(
(set, get) => ({
activeCell: null,
replayingOutsideAction: false,
setActiveCell: (session) => set({ activeCell: session }, false, 'editableCell/setActiveCell'),
updateActiveSave: (id, save) => {
const { activeCell } = get();
if (activeCell?.id === id) {
set({ activeCell: { ...activeCell, save } }, false, 'editableCell/updateActiveSave');
}
},
clearIfActive: (id) => {
const { activeCell } = get();
if (activeCell?.id === id) set({ activeCell: null }, false, 'editableCell/clearIfActive');
},
setReplayingOutsideAction: (value) =>
set({ replayingOutsideAction: value }, false, 'editableCell/setReplayingOutsideAction'),
resetEditableCell: () =>
set(
{ activeCell: null, replayingOutsideAction: false },
false,
'editableCell/reset',
),
}),
{ name: 'editable-cell-store', enabled: import.meta.env.DEV },
),
);