fix(integration): simplify manual DingTalk sync setup

This commit is contained in:
2026-07-13 14:01:45 +08:00
parent effa34434b
commit 0515e6ed27
6 changed files with 203 additions and 88 deletions

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import {
Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider,
Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
Row, Col, List,
} from 'antd';
import {
@@ -15,12 +15,15 @@ import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../../components/PermissionButton';
import {
buildDingTalkConfigPayload,
isAppSecretRequired,
type DingTalkConfigFormValues,
} from './integration-config-form';
interface DingTalkConfig {
agentId: string;
appSecret: string;
corpId: string;
startEnable: boolean;
}
interface DingOrgTreeNodeExt {
@@ -94,9 +97,9 @@ const IntegrationConfigPage: React.FC = () => {
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<DingTalkConfig | null>(null);
const [verified, setVerified] = useState<boolean | null>(null);
const [form] = Form.useForm<DingTalkConfig>();
const [form] = Form.useForm<DingTalkConfigFormValues>();
// ── Sync Users Tab ──
// ── Manual organization sync ──
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -137,9 +140,10 @@ const IntegrationConfigPage: React.FC = () => {
const handleSave = async () => {
const values = await form.validateFields();
const payload = buildDingTalkConfigPayload(values);
setSaving(true);
try {
await api.post('/integration/config', { type: 'DINGTALK', config: values });
await api.post('/integration/config', { type: 'DINGTALK', config: payload });
message.success('配置已保存');
await fetchConfig();
} catch (e: unknown) {
@@ -152,11 +156,12 @@ const IntegrationConfigPage: React.FC = () => {
const handleTest = async () => {
const values = await form.validateFields();
const payload = buildDingTalkConfigPayload(values);
setTesting(true);
try {
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
type: 'DINGTALK',
config: values,
config: payload,
});
setVerified(res.success);
message.success(res.message);
@@ -345,12 +350,8 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
? [
{
key: 'sync-users',
label: '同步用户',
children: (
const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
? (
<div>
<Alert
type="info"
@@ -519,77 +520,100 @@ const IntegrationConfigPage: React.FC = () => {
/>
</Modal>
</div>
),
},
]
: [];
const tabItems = [
{
key: 'config',
label: '配置',
children: (
<Spin spinning={loading}>
{config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
<Descriptions.Item label="启用同步">
<Tag color={config.startEnable ? 'green' : 'default'}>
{config.startEnable ? '已启用' : '未启用'}
</Tag>
</Descriptions.Item>
</Descriptions>
)}
<Alert
type="info"
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
style={{ marginBottom: 24 }}
showIcon
/>
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
<Form.Item name="corpId" label="CorpId企业ID" rules={[{ required: true, message: '请输入 CorpId' }]}>
<Input placeholder="dingxxxxxxxx" />
</Form.Item>
<Form.Item name="agentId" label="AppKey应用凭证" rules={[{ required: true, message: '请输入 AppKey' }]}>
<Input placeholder="从钉钉开放平台获取" />
</Form.Item>
<Form.Item
name="appSecret"
label="AppSecret应用密钥"
rules={[{ required: true, message: '请输入 AppSecret' }]}
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
>
<Input.Password placeholder="从钉钉开放平台获取" />
</Form.Item>
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
<Switch />
</Form.Item>
<Space>
<PermissionButton permission="integration:trigger" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</PermissionButton>
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
</Button>
</Space>
</Form>
</Spin>
),
},
...syncTabItems,
];
)
: null;
return (
<Card title="钉钉集成配置" extra={
<Space>
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success"></Tag>}
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error"></Tag>}
</Space>
}>
<Tabs items={tabItems} />
<Card
title="钉钉集成配置"
extra={
<Space>
{verified === true && (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
)}
{verified === false && (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</Space>
}
>
<Spin spinning={loading}>
{config && (
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
<Descriptions.Item label="同步方式"></Descriptions.Item>
</Descriptions>
)}
<Alert
type="info"
message="配置钉钉应用凭证后,可在本页手动获取组织架构并导入用户。排班同步仍在排课管理中手动触发。"
style={{ marginBottom: 24 }}
showIcon
/>
<Form form={form} layout="vertical" style={{ maxWidth: 520 }}>
<Form.Item
name="corpId"
label="CorpId企业ID"
rules={[{ required: true, message: '请输入 CorpId' }]}
>
<Input placeholder="dingxxxxxxxx" />
</Form.Item>
<Form.Item
name="agentId"
label="AppKey应用凭证"
rules={[{ required: true, message: '请输入 AppKey' }]}
>
<Input placeholder="从钉钉开放平台获取" />
</Form.Item>
<Form.Item
name="appSecret"
label="AppSecret应用密钥"
rules={[
{
required: isAppSecretRequired(!!config),
message: '首次配置请输入 AppSecret',
},
]}
extra={
config
? '已保存密钥;留空保持原值,输入新值将替换原密钥'
: '首次配置需要填写完整 AppSecret'
}
>
<Input.Password
placeholder={config ? '留空保持已保存的密钥' : '从钉钉开放平台获取'}
/>
</Form.Item>
<Space>
<PermissionButton
permission="integration:trigger"
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={handleSave}
>
</PermissionButton>
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
</Button>
</Space>
</Form>
{syncPanel && (
<>
<Divider titlePlacement="start"></Divider>
{syncPanel}
</>
)}
</Spin>
</Card>
);
};

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
buildDingTalkConfigPayload,
isAppSecretRequired,
} from './integration-config-form';
describe('DingTalk integration config form', () => {
it('requires AppSecret only for the first configuration', () => {
expect(isAppSecretRequired(false)).toBe(true);
expect(isAppSecretRequired(true)).toBe(false);
});
it('builds a manual-sync config without the retired startEnable flag', () => {
expect(
buildDingTalkConfigPayload({
corpId: 'ding-corp',
agentId: 'app-key',
appSecret: '',
}),
).toEqual({
corpId: 'ding-corp',
agentId: 'app-key',
appSecret: undefined,
});
});
});

View File

@@ -0,0 +1,13 @@
export interface DingTalkConfigFormValues {
agentId: string;
appSecret?: string;
corpId: string;
}
export const isAppSecretRequired = (hasSavedConfig: boolean) => !hasSavedConfig;
export const buildDingTalkConfigPayload = (values: DingTalkConfigFormValues) => ({
corpId: values.corpId.trim(),
agentId: values.agentId.trim(),
appSecret: values.appSecret?.trim() || undefined,
});