From 9cc9ed09dfef80dde915eac8d6ac5b8d273c8d0c Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:04:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20EditableCell=20=E8=B7=A8?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=A0=BC=E5=8D=8F=E8=B0=83=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=BF=81=E5=85=A5=20zustand=EF=BC=8C=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=BA=A7=E5=8D=95=E4=BE=8B=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/EditableCell/index.tsx | 25 ++++---- .../editableCell/editableCellStore.test.ts | 49 ++++++++++++++++ .../store/editableCell/editableCellStore.ts | 57 +++++++++++++++++++ 3 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 apps/admin/src/store/editableCell/editableCellStore.test.ts create mode 100644 apps/admin/src/store/editableCell/editableCellStore.ts diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index 8e63384..5e0f12f 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -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 { onSave: (value: Value) => Promise; } -let activeCell: { id: string; save: () => Promise } | 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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); diff --git a/apps/admin/src/store/editableCell/editableCellStore.test.ts b/apps/admin/src/store/editableCell/editableCellStore.test.ts new file mode 100644 index 0000000..3855606 --- /dev/null +++ b/apps/admin/src/store/editableCell/editableCellStore.test.ts @@ -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); + }); +}); diff --git a/apps/admin/src/store/editableCell/editableCellStore.ts b/apps/admin/src/store/editableCell/editableCellStore.ts new file mode 100644 index 0000000..ad9fb14 --- /dev/null +++ b/apps/admin/src/store/editableCell/editableCellStore.ts @@ -0,0 +1,57 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +/** + * EditableCell 跨单元格协调状态。 + * + * 原实现为模块级单例(activeCell / replayingOutsideAction),HMR、路由 + * 卸载与测试之间不会重置;迁入 zustand 后行为一致且可重置、可测试。 + * 所有读写都通过 getState() 命令式完成,不订阅渲染,避免无谓重渲染。 + */ +export interface EditableCellSession { + id: string; + save: () => Promise; +} + +interface EditableCellStore { + /** 当前处于编辑态的单元格(全局唯一) */ + activeCell: EditableCellSession | null; + /** 外部点击回放期间置位,抑制 document 级 pointerdown 的二次保存 */ + replayingOutsideAction: boolean; + setActiveCell: (session: EditableCellSession | null) => void; + /** 编辑中更新 save 闭包(仅当仍是最新激活单元格时生效) */ + updateActiveSave: (id: string, save: () => Promise) => void; + /** 若指定 id 仍是最新激活单元格则清空 */ + clearIfActive: (id: string) => void; + setReplayingOutsideAction: (value: boolean) => void; + resetEditableCell: () => void; +} + +export const useEditableCellStore = create()( + 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 }, + ), +);