From 01cceea237a1dd1c3990bc1c63f192a09a1bb672 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:07:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin):=20=E9=92=89=E9=92=89=E9=9B=86?= =?UTF-8?q?=E6=88=90=E9=85=8D=E7=BD=AE=E7=BC=93=E5=AD=98=E8=BF=81=E5=85=A5?= =?UTF-8?q?=20zustand=20=E9=A1=B5=E9=9D=A2=E7=BA=A7=20store=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=A8=A1=E5=9D=97=E7=BA=A7=E5=8D=95=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/IntegrationConfig/index.tsx | 20 ++--- ...tegration-config-cache.integration.test.ts | 39 --------- .../integration-config-cache.ts | 62 ------------- ...integrationConfigStore.integration.test.ts | 40 +++++++++ .../integrationConfigStore.ts | 86 +++++++++++++++++++ 5 files changed, 135 insertions(+), 112 deletions(-) delete mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-cache.integration.test.ts delete mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts create mode 100644 apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts create mode 100644 apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index 9ebaecf..d37104a 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -30,12 +30,7 @@ import { isAppSecretRequired, type DingTalkConfigFormValues, } from './integration-config-form'; -import { - cacheDingTalkDraft, - cacheDingTalkServerSnapshot, - commitDingTalkConfig, - readDingTalkConfigCache, -} from './integration-config-cache'; +import { useIntegrationConfigStore } from './integrationConfigStore'; import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel'; interface DingTalkConfig { @@ -44,7 +39,10 @@ interface DingTalkConfig { } const IntegrationConfigPage: React.FC = () => { - const initialCache = useMemo(() => readDingTalkConfigCache(), []); + const initialCache = useMemo(() => { + const { loaded, config, verified, formValues, dirty } = useIntegrationConfigStore.getState(); + return { loaded, config, verified, formValues, dirty }; + }, []); const { hasPermission, hasAllPermissions } = usePermission(); const canCreateClass = hasPermission('class:create'); const [saving, setSaving] = useState(false); @@ -93,8 +91,8 @@ const IntegrationConfigPage: React.FC = () => { // 服务端配置同步进 localStorage 缓存,并回填表单 useEffect(() => { - cacheDingTalkServerSnapshot(config, verified); - if (config) form.setFieldsValue(readDingTalkConfigCache().formValues); + useIntegrationConfigStore.getState().cacheServerSnapshot(config, verified); + if (config) form.setFieldsValue(useIntegrationConfigStore.getState().formValues); }, [config, verified, form]); const handleSave = async () => { @@ -104,7 +102,7 @@ const IntegrationConfigPage: React.FC = () => { try { await saveMutation.mutateAsync(payload); message.success('配置已保存'); - commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId }); + useIntegrationConfigStore.getState().commitConfig({ corpId: payload.corpId, agentId: payload.agentId }); form.setFieldValue('appSecret', undefined); } catch { // 错误提示由 useApiMutation 统一处理 @@ -180,7 +178,7 @@ const IntegrationConfigPage: React.FC = () => { form={form} layout="vertical" initialValues={initialCache.formValues} - onValuesChange={(_changed, values) => cacheDingTalkDraft(values)} + onValuesChange={(_changed, values) => useIntegrationConfigStore.getState().cacheDraft(values)} style={{ maxWidth: 520 }} > { - beforeEach(resetDingTalkConfigCache); - - it('keeps an unsaved secret when a background refresh returns', () => { - cacheDingTalkDraft({ corpId: 'draft-corp', agentId: 'draft-key', appSecret: 'draft-secret' }); - cacheDingTalkServerSnapshot({ corpId: 'saved-corp', agentId: 'saved-key' }, true); - - expect(readDingTalkConfigCache()).toMatchObject({ - loaded: true, - dirty: true, - config: { corpId: 'saved-corp', agentId: 'saved-key' }, - formValues: { - corpId: 'draft-corp', - agentId: 'draft-key', - appSecret: 'draft-secret', - }, - }); - }); - - it('clears the secret after a successful save', () => { - cacheDingTalkDraft({ corpId: 'corp', agentId: 'key', appSecret: 'secret' }); - commitDingTalkConfig({ corpId: 'corp', agentId: 'key' }); - - expect(readDingTalkConfigCache()).toMatchObject({ - loaded: true, - dirty: false, - formValues: { corpId: 'corp', agentId: 'key', appSecret: undefined }, - }); - }); -}); diff --git a/apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts b/apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts deleted file mode 100644 index 7fecfc2..0000000 --- a/apps/admin/src/pages/IntegrationConfig/integration-config-cache.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { DingTalkConfigFormValues } from './integration-config-form'; - -export interface DingTalkSavedConfig { - agentId: string; - corpId: string; -} - -interface DingTalkConfigCache { - loaded: boolean; - config: DingTalkSavedConfig | null; - verified: boolean | null; - formValues: Partial; - dirty: boolean; -} - -const cache: DingTalkConfigCache = { - loaded: false, - config: null, - verified: null, - formValues: {}, - dirty: false, -}; - -export function readDingTalkConfigCache(): DingTalkConfigCache { - return { - ...cache, - config: cache.config ? { ...cache.config } : null, - formValues: { ...cache.formValues }, - }; -} - -export function cacheDingTalkDraft(values: Partial): void { - cache.formValues = { ...values }; - cache.dirty = true; -} - -export function cacheDingTalkServerSnapshot( - config: DingTalkSavedConfig | null, - verified: boolean | null, -): void { - cache.loaded = true; - cache.config = config ? { ...config } : null; - cache.verified = verified; - if (!cache.dirty) { - cache.formValues = config ? { ...config, appSecret: undefined } : {}; - } -} - -export function commitDingTalkConfig(config: DingTalkSavedConfig): void { - cache.loaded = true; - cache.config = { ...config }; - cache.formValues = { ...config, appSecret: undefined }; - cache.dirty = false; -} - -export function resetDingTalkConfigCache(): void { - cache.loaded = false; - cache.config = null; - cache.verified = null; - cache.formValues = {}; - cache.dirty = false; -} diff --git a/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts new file mode 100644 index 0000000..a212395 --- /dev/null +++ b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.integration.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useIntegrationConfigStore } from './integrationConfigStore'; + +const { getState } = useIntegrationConfigStore; + +const readCache = () => { + const { loaded, config, verified, formValues, dirty } = getState(); + return { loaded, config, verified, formValues, dirty }; +}; + +describe('DingTalk integration config page cache', () => { + beforeEach(() => getState().reset()); + + it('keeps an unsaved secret when a background refresh returns', () => { + getState().cacheDraft({ corpId: 'draft-corp', agentId: 'draft-key', appSecret: 'draft-secret' }); + getState().cacheServerSnapshot({ corpId: 'saved-corp', agentId: 'saved-key' }, true); + + expect(readCache()).toMatchObject({ + loaded: true, + dirty: true, + config: { corpId: 'saved-corp', agentId: 'saved-key' }, + formValues: { + corpId: 'draft-corp', + agentId: 'draft-key', + appSecret: 'draft-secret', + }, + }); + }); + + it('clears the secret after a successful save', () => { + getState().cacheDraft({ corpId: 'corp', agentId: 'key', appSecret: 'secret' }); + getState().commitConfig({ corpId: 'corp', agentId: 'key' }); + + expect(readCache()).toMatchObject({ + loaded: true, + dirty: false, + formValues: { corpId: 'corp', agentId: 'key', appSecret: undefined }, + }); + }); +}); diff --git a/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts new file mode 100644 index 0000000..aaa677f --- /dev/null +++ b/apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import type { DingTalkConfigFormValues } from './integration-config-form'; + +export interface DingTalkSavedConfig { + agentId: string; + corpId: string; +} + +interface DingTalkConfigState { + /** 服务端配置是否已加载过(区分「未加载」与「确实为空」) */ + loaded: boolean; + config: DingTalkSavedConfig | null; + verified: boolean | null; + /** 表单草稿:保留未保存的 appSecret 等敏感字段 */ + formValues: Partial; + /** 是否存在未保存的草稿 */ + dirty: boolean; +} + +interface DingTalkConfigActions { + /** 表单变化时缓存草稿 */ + cacheDraft: (values: Partial) => void; + /** 后台拉取服务端配置成功时缓存快照(不覆盖已有草稿) */ + cacheServerSnapshot: (config: DingTalkSavedConfig | null, verified: boolean | null) => void; + /** 保存成功后提交配置并清空草稿 */ + commitConfig: (config: DingTalkSavedConfig) => void; + reset: () => void; +} + +export type DingTalkConfigStore = DingTalkConfigState & DingTalkConfigActions; + +const initialDingTalkConfig: DingTalkConfigState = { + loaded: false, + config: null, + verified: null, + formValues: {}, + dirty: false, +}; + +/** + * 钉钉集成页面的表单/配置会话缓存(页面级 store)。 + * + * 原实现为模块级单例(integration-config-cache.ts):在 RouteKeeper 淘汰 + * 页面或重新进入页面时仍保留草稿。迁入 zustand 后语义一致、可重置可测试。 + */ +export const useIntegrationConfigStore = create()( + devtools( + (set, get) => ({ + ...initialDingTalkConfig, + cacheDraft: (values) => + set( + { formValues: { ...values }, dirty: true }, + false, + 'integrationConfig/cacheDraft', + ), + cacheServerSnapshot: (config, verified) => { + const { dirty, formValues } = get(); + set( + { + loaded: true, + config: config ? { ...config } : null, + verified, + formValues: dirty ? formValues : config ? { ...config, appSecret: undefined } : {}, + }, + false, + 'integrationConfig/cacheServerSnapshot', + ); + }, + commitConfig: (config) => + set( + { + loaded: true, + config: { ...config }, + formValues: { ...config, appSecret: undefined }, + dirty: false, + }, + false, + 'integrationConfig/commitConfig', + ), + reset: () => + set({ ...initialDingTalkConfig }, false, 'integrationConfig/reset'), + }), + { name: 'integration-config-store', enabled: import.meta.env.DEV }, + ), +);