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