refactor(admin): 钉钉集成配置缓存迁入 zustand 页面级 store,移除模块级单例
This commit is contained in:
@@ -30,12 +30,7 @@ import {
|
|||||||
isAppSecretRequired,
|
isAppSecretRequired,
|
||||||
type DingTalkConfigFormValues,
|
type DingTalkConfigFormValues,
|
||||||
} from './integration-config-form';
|
} from './integration-config-form';
|
||||||
import {
|
import { useIntegrationConfigStore } from './integrationConfigStore';
|
||||||
cacheDingTalkDraft,
|
|
||||||
cacheDingTalkServerSnapshot,
|
|
||||||
commitDingTalkConfig,
|
|
||||||
readDingTalkConfigCache,
|
|
||||||
} from './integration-config-cache';
|
|
||||||
import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel';
|
import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel';
|
||||||
|
|
||||||
interface DingTalkConfig {
|
interface DingTalkConfig {
|
||||||
@@ -44,7 +39,10 @@ interface DingTalkConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IntegrationConfigPage: React.FC = () => {
|
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 { hasPermission, hasAllPermissions } = usePermission();
|
||||||
const canCreateClass = hasPermission('class:create');
|
const canCreateClass = hasPermission('class:create');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -93,8 +91,8 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
// 服务端配置同步进 localStorage 缓存,并回填表单
|
// 服务端配置同步进 localStorage 缓存,并回填表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cacheDingTalkServerSnapshot(config, verified);
|
useIntegrationConfigStore.getState().cacheServerSnapshot(config, verified);
|
||||||
if (config) form.setFieldsValue(readDingTalkConfigCache().formValues);
|
if (config) form.setFieldsValue(useIntegrationConfigStore.getState().formValues);
|
||||||
}, [config, verified, form]);
|
}, [config, verified, form]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -104,7 +102,7 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await saveMutation.mutateAsync(payload);
|
await saveMutation.mutateAsync(payload);
|
||||||
message.success('配置已保存');
|
message.success('配置已保存');
|
||||||
commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId });
|
useIntegrationConfigStore.getState().commitConfig({ corpId: payload.corpId, agentId: payload.agentId });
|
||||||
form.setFieldValue('appSecret', undefined);
|
form.setFieldValue('appSecret', undefined);
|
||||||
} catch {
|
} catch {
|
||||||
// 错误提示由 useApiMutation 统一处理
|
// 错误提示由 useApiMutation 统一处理
|
||||||
@@ -180,7 +178,7 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={initialCache.formValues}
|
initialValues={initialCache.formValues}
|
||||||
onValuesChange={(_changed, values) => cacheDingTalkDraft(values)}
|
onValuesChange={(_changed, values) => useIntegrationConfigStore.getState().cacheDraft(values)}
|
||||||
style={{ maxWidth: 520 }}
|
style={{ maxWidth: 520 }}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user