refactor(admin): 钉钉集成配置缓存迁入 zustand 页面级 store,移除模块级单例

This commit is contained in:
2026-08-08 17:07:13 +08:00
parent 9cc9ed09df
commit 01cceea237
5 changed files with 135 additions and 112 deletions

View File

@@ -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 }}
>
<Form.Item

View File

@@ -1,39 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
cacheDingTalkDraft,
cacheDingTalkServerSnapshot,
commitDingTalkConfig,
readDingTalkConfigCache,
resetDingTalkConfigCache,
} from './integration-config-cache';
describe('DingTalk integration config page cache', () => {
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 },
});
});
});

View File

@@ -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<DingTalkConfigFormValues>;
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<DingTalkConfigFormValues>): 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;
}

View File

@@ -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 },
});
});
});

View File

@@ -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<DingTalkConfigFormValues>;
/** 是否存在未保存的草稿 */
dirty: boolean;
}
interface DingTalkConfigActions {
/** 表单变化时缓存草稿 */
cacheDraft: (values: Partial<DingTalkConfigFormValues>) => 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<DingTalkConfigStore>()(
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 },
),
);