245 lines
8.0 KiB
TypeScript
245 lines
8.0 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||
import { validateResponse } from '../../utils/validate';
|
||
import { integrationConfigSchema } from '../../api/schemas';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
Descriptions,
|
||
Divider,
|
||
Form,
|
||
Input,
|
||
Space,
|
||
Spin,
|
||
Tag,
|
||
} from 'antd';
|
||
import {
|
||
ApiOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
SaveOutlined,
|
||
} from '@ant-design/icons';
|
||
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';
|
||
import {
|
||
cacheDingTalkDraft,
|
||
cacheDingTalkServerSnapshot,
|
||
commitDingTalkConfig,
|
||
readDingTalkConfigCache,
|
||
} from './integration-config-cache';
|
||
import { IntegrationOrgSyncPanel } from './IntegrationOrgSyncPanel';
|
||
|
||
interface DingTalkConfig {
|
||
agentId: string;
|
||
corpId: string;
|
||
}
|
||
|
||
const IntegrationConfigPage: React.FC = () => {
|
||
const initialCache = useMemo(() => readDingTalkConfigCache(), []);
|
||
const { hasPermission, hasAllPermissions } = usePermission();
|
||
const canCreateClass = hasPermission('class:create');
|
||
const [saving, setSaving] = useState(false);
|
||
const [testing, setTesting] = useState(false);
|
||
const [form] = Form.useForm<DingTalkConfigFormValues>();
|
||
const queryClient = useQueryClient();
|
||
|
||
const {
|
||
data: serverConfig = { config: initialCache.config, verified: initialCache.verified },
|
||
isLoading: configLoading,
|
||
isFetching: configFetching,
|
||
} = useQuery<{ config: DingTalkConfig | null; verified: boolean | null }>({
|
||
queryKey: ['integration', 'config'],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await api.get<{
|
||
success: boolean;
|
||
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
|
||
}>('/integration/config');
|
||
const validated = validateResponse<{
|
||
success: boolean;
|
||
data: Array<{ type: string; verify: boolean; config: DingTalkConfig }>;
|
||
}>(integrationConfigSchema, res);
|
||
const dt = validated.data?.find((c) => c.type === 'DINGTALK');
|
||
return { config: dt?.config ?? null, verified: dt ? dt.verify : null };
|
||
} catch {
|
||
// not configured
|
||
return { config: initialCache.config, verified: initialCache.verified };
|
||
}
|
||
},
|
||
});
|
||
const config = serverConfig.config;
|
||
const verified = serverConfig.verified;
|
||
const loading = !initialCache.loaded && (configLoading || configFetching);
|
||
|
||
const saveMutation = useApiMutation(
|
||
async (payload: Record<string, unknown>) =>
|
||
api.post('/integration/config', { type: 'DINGTALK', config: payload }),
|
||
{ invalidate: [['integration', 'config']] },
|
||
);
|
||
|
||
// 初始表单值来自本地缓存(外部存储同步)
|
||
useEffect(() => {
|
||
form.setFieldsValue(initialCache.formValues);
|
||
}, [form, initialCache]);
|
||
|
||
// 服务端配置同步进 localStorage 缓存,并回填表单
|
||
useEffect(() => {
|
||
cacheDingTalkServerSnapshot(config, verified);
|
||
if (config) form.setFieldsValue(readDingTalkConfigCache().formValues);
|
||
}, [config, verified, form]);
|
||
|
||
const handleSave = async () => {
|
||
const values = await form.validateFields();
|
||
const payload = buildDingTalkConfigPayload(values);
|
||
setSaving(true);
|
||
try {
|
||
await saveMutation.mutateAsync(payload);
|
||
message.success('配置已保存');
|
||
commitDingTalkConfig({ corpId: payload.corpId, agentId: payload.agentId });
|
||
form.setFieldValue('appSecret', undefined);
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
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: payload,
|
||
},
|
||
);
|
||
queryClient.setQueryData(['integration', 'config'], (prev) => ({
|
||
...(prev ?? { config: initialCache.config, verified: initialCache.verified }),
|
||
verified: res.success,
|
||
}));
|
||
message.success(res.message);
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string };
|
||
queryClient.setQueryData(['integration', 'config'], (prev) => ({
|
||
...(prev ?? { config: initialCache.config, verified: initialCache.verified }),
|
||
verified: false,
|
||
}));
|
||
message.error(err?.message || '连接失败');
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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"
|
||
initialValues={initialCache.formValues}
|
||
onValuesChange={(_changed, values) => cacheDingTalkDraft(values)}
|
||
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>
|
||
|
||
{config && hasAllPermissions('sync:read', 'class:view', 'class:edit') && (
|
||
<>
|
||
<Divider titlePlacement="start">组织用户导入</Divider>
|
||
<IntegrationOrgSyncPanel canCreateClass={canCreateClass} />
|
||
</>
|
||
)}
|
||
</Spin>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
export default IntegrationConfigPage;
|