Files
gongxue-base/apps/admin/src/pages/IntegrationConfig/index.tsx

245 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;