feat: wecom custom domain

This commit is contained in:
Finley Ge 2025-12-07 23:25:55 +08:00
parent 237c6eaa21
commit edb83bc2b9
No known key found for this signature in database
GPG Key ID: 4C7633901042E27A
11 changed files with 288 additions and 24 deletions

View File

@ -137,6 +137,8 @@ export type FastGPTFeConfigsType = {
volcengine?: string;
};
};
ip_whitelist?: string;
};
export type SystemEnvType = {

View File

@ -29,3 +29,9 @@ export type CreateCustomDomainBody = {
};
export type ProviderEnum = z.infer<typeof ProviderEnum>;
export type CustomDomainStatusEnum = z.infer<typeof CustomDomainStatusEnum>;
export type UpdateDomainVerifyFileBody = {
domain: string;
path: string;
content: string;
};

View File

@ -31,6 +31,10 @@
"custom_domain.delete_confirm": "确认删除该自定义域名?",
"custom_domain.status.active": "已生效",
"custom_domain.status.inactive": "已失效",
"custom_domain.domain_verify": "域名校验",
"custom_domain.domain_verify.path": "文件路径",
"custom_domain.domain_verify.content": "文件内容",
"custom_domain.domain_verify.desc": "保存后,访问 {{domain}}/{{path}} 将返回 {{content}}",
"day": "天",
"default_model": "预设模型",
"default_model_config": "默认模型配置",

View File

@ -301,6 +301,7 @@
"pro_modal_title": "商业版专享!",
"pro_modal_unlock_button": "去解锁",
"publish_channel": "发布渠道",
"publish_channel.wecom.empty": "发布到企业微信机器人,请先 <a>绑定自定义域名</a>,并且通过域名校验。",
"publish_success": "发布成功",
"question_guide_tip": "对话结束后,会为你生成 3 个引导性问题。",
"reasoning_response": "输出思考",

View File

@ -41,5 +41,6 @@
"dingtalk.create_modal_title": "创建钉钉机器人",
"dingtalk.edit_modal_title": "编辑钉钉机器人",
"dingtalk.title": "发布到钉钉机器人",
"dingtalk.api": "钉钉 API"
"dingtalk.api": "钉钉 API",
"use_default_domain": "使用默认域名"
}

View File

@ -0,0 +1,101 @@
import {
ModalBody,
Box,
Radio,
Flex,
Text,
Input,
InputGroup,
InputRightElement,
Tag,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
IconButton,
Button,
ModalFooter,
Link,
Grid
} from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/common/MyModal';
import { useTranslation, Trans } from 'next-i18next';
import Icon from '@fastgpt/web/components/common/Icon';
import type { IconNameType } from '@fastgpt/web/components/common/Icon/type';
import { useEffect, useMemo, useState } from 'react';
import { providerMap } from '@/web/support/customDomain/const';
import type { ProviderEnum } from '@fastgpt/global/support/customDomain/type';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { generateCNAMEDomain } from '@fastgpt/global/support/customDomain/utils';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import {
activeCustomDomain,
checkCustomDomainDNSResolve,
createCustomDomain,
updateCustomDomainVerifyFile
} from '@/web/support/customDomain/api';
import { getDocPath } from '@/web/common/system/doc';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
import { useForm } from 'react-hook-form';
import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel';
function CreateCustomDomainModal<T extends 'create' | 'refresh'>({
onClose,
domain
}: {
onClose: () => void;
domain: string;
}) {
const { t } = useTranslation();
const { watch, handleSubmit, register } = useForm({
defaultValues: {
path: '',
content: ''
}
});
const path = watch('path');
const content = watch('content');
const { runAsync: updateVerifyFile, loading: isUpdating } = useRequest2(
updateCustomDomainVerifyFile,
{
manual: true,
onSuccess: () => {
onClose();
},
successToast: t('common:Success')
}
);
return (
<MyModal isOpen onClose={onClose} title={t('account:custom_domain.domain_verify')} minW="800px">
<ModalBody>
<Grid gridTemplateColumns="1fr 1fr" gap="16px">
<Box>
<FormLabel required>{t('account:custom_domain.domain_verify.path')}</FormLabel>
<Input {...register('path')} />
</Box>
<Box>
<FormLabel required>{t('account:custom_domain.domain_verify.content')}</FormLabel>
<Input {...register('content')} />
</Box>
</Grid>
<Box mt="2">{t('account:custom_domain.domain_verify.desc', { domain, path, content })}</Box>
</ModalBody>
<ModalFooter>
<Button
onClick={handleSubmit(() => updateVerifyFile({ domain, path, content }))}
isLoading={isUpdating}
>
{t('common:Confirm')}
</Button>
</ModalFooter>
</MyModal>
);
}
export default CreateCustomDomainModal;

View File

@ -75,7 +75,10 @@ const WecomEditModal = ({
<Box color="myGray.600">{t('publish:wecom.api')}</Box>
{feConfigs?.docUrl && (
<Link
href={feConfigs.openAPIDocUrl || getDocPath('/docs/use-cases/wecom-bot')}
href={
feConfigs.openAPIDocUrl ||
getDocPath('/docs/use-cases/external-integration/wecom')
}
target={'_blank'}
ml={2}
color={'primary.500'}

View File

@ -10,7 +10,8 @@ import {
Th,
Td,
Tbody,
useDisclosure
useDisclosure,
Link
} from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useLoading } from '@fastgpt/web/hooks/useLoading';
@ -19,7 +20,7 @@ import { formatTimeToChatTime } from '@fastgpt/global/common/string/time';
import { defaultOutLinkForm } from '@/web/core/app/constants';
import type { WecomAppType, OutLinkEditType } from '@fastgpt/global/support/outLink/type.d';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import { useTranslation } from 'next-i18next';
import { Trans, useTranslation } from 'next-i18next';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import dayjs from 'dayjs';
import dynamic from 'next/dynamic';
@ -206,7 +207,24 @@ const Wecom = ({ appId }: { appId: string }) => {
/>
)}
{shareChatList.length === 0 && !isFetching && (
<EmptyTip text={t('common:core.app.share.Not share link')}> </EmptyTip>
<EmptyTip
text={
<Trans
i18nKey="app:publish_channel.wecom.empty"
components={{
a: (
<Link
color="primary.600"
key="link"
href="/account/customDomain"
target="_blank"
rel="noopener noreferrer"
/>
)
}}
/>
}
></EmptyTip>
)}
<Loading loading={isFetching} fixed={false} />
{showShareLinkModalOpen && (

View File

@ -4,6 +4,11 @@ import MyModal from '@fastgpt/web/components/common/MyModal';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'next-i18next';
import MyImage from '@fastgpt/web/components/common/Image/MyImage';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useRequest2 } from '@fastgpt/web/hooks/useRequest';
import { listCustomDomain } from '@/web/support/customDomain/api';
import { useState, useMemo } from 'react';
import MySelect from '@fastgpt/web/components/common/MySelect';
export type ShowShareLinkModalProps = {
shareLink: string;
@ -14,10 +19,66 @@ export type ShowShareLinkModalProps = {
function ShowShareLinkModal({ shareLink, onClose, img }: ShowShareLinkModalProps) {
const { copyData } = useCopyData();
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const [customDomain, setCustomDomain] = useState<string | undefined>(undefined);
const { data: customDomainList = [] } = useRequest2(listCustomDomain, {
manual: false
});
// 从 shareLink 中提取原始域名
const originalDomain = useMemo(() => {
try {
const url = new URL(shareLink);
return url.origin;
} catch {
return '';
}
}, [shareLink]);
// 计算显示的分享链接(使用自定义域名替换原始域名)
const displayShareLink = useMemo(() => {
if (!customDomain || !originalDomain) {
return shareLink;
}
return shareLink.replace(originalDomain, `https://${customDomain}`);
}, [shareLink, customDomain, originalDomain]);
// 处理域名选择选项
const domainOptions = useMemo(() => {
const options = [
{
label: t('publish:use_default_domain') || '使用默认域名',
value: ''
}
];
// 只显示已激活的自定义域名
const activeDomains = customDomainList
.filter((item) => item.status === 'active')
.map((item) => ({
label: item.domain,
value: item.domain
}));
return [...options, ...activeDomains];
}, [customDomainList, t]);
return (
<MyModal onClose={onClose} title={t('publish:show_share_link_modal_title')}>
<ModalBody>
{/* 自定义域名选择器 */}
{domainOptions.length > 1 && (
<Box mb={4}>
<MySelect
value={customDomain || ''}
list={domainOptions}
onChange={(value) => setCustomDomain(value || undefined)}
/>
</Box>
)}
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'}>
<Flex
p={3}
@ -33,16 +94,41 @@ function ShowShareLinkModal({ shareLink, onClose, img }: ShowShareLinkModalProps
color={'myGray.600'}
cursor={'pointer'}
_hover={{ color: 'primary.500' }}
onClick={() => copyData(shareLink)}
onClick={() => copyData(displayShareLink)}
/>
</Flex>
<Box whiteSpace={'pre'} p={3} overflowX={'auto'}>
{shareLink}
{displayShareLink}
</Box>
</Box>
<Box mt="4" borderRadius="0.5rem" border="1px" borderStyle="solid" borderColor="myGray.200">
<MyImage src={img} borderRadius="0.5rem" alt="" />
</Box>
<Box borderRadius={'md'} bg={'myGray.100'} overflow={'hidden'} fontSize={'sm'} mt="4">
<Flex
p={3}
bg={'myWhite.500'}
border="base"
borderTopLeftRadius={'md'}
borderTopRightRadius={'md'}
>
<Box flex="1">IP </Box>
<MyIcon
name={'copy'}
w={'16px'}
color={'myGray.600'}
cursor={'pointer'}
_hover={{ color: 'primary.500' }}
onClick={() => copyData(feConfigs?.ip_whitelist || '')}
/>
</Flex>
<Box p={3} wordBreak={'break-all'}>
{feConfigs.ip_whitelist}
</Box>
</Box>
</ModalBody>
</MyModal>
);

View File

@ -1,5 +1,4 @@
import AccountContainer from '@/pageComponents/account/AccountContainer';
import IconButton from '@/pageComponents/account/team/OrgManage/IconButton';
import { serviceSideProps } from '@/web/common/i18n/utils';
import { deleteCustomDomain, listCustomDomain } from '@/web/support/customDomain/api';
import {
@ -28,6 +27,10 @@ const CreateCustomDomainModal = dynamic(
() => import('@/pageComponents/account/customDomain/createModal')
);
const DomainVerifyModal = dynamic(
() => import('@/pageComponents/account/customDomain/domainVerifyModal')
);
const CustomDomain = () => {
const { t } = useTranslation();
const {
@ -43,6 +46,12 @@ const CustomDomain = () => {
onClose: onCloseCreateModal
} = useDisclosure();
const {
isOpen: isOpenDomainVerify,
onOpen: onOpenDomainVerify,
onClose: onCloseDomainVerify
} = useDisclosure();
const { runAsync: onDelete, loading: loadingDelete } = useRequest2(deleteCustomDomain, {
manual: true,
successToast: t('common:Success'),
@ -66,7 +75,7 @@ const CustomDomain = () => {
{t('account:custom_domain')}
{customDomainList?.length ? `: (${customDomainList.length}/3)` : <></>}
</Box>
<Button variant="outline" onClick={onOpenCreateModal}>
<Button variant="whitePrimaryOutline" onClick={onOpenCreateModal}>
{t('common:Add')}
</Button>
</Flex>
@ -90,23 +99,37 @@ const CustomDomain = () => {
<Td>{t(providerMap[customDomain.provider])}</Td>
<Td>{t(customDomainStatusMap[customDomain.status])}</Td>
<Td>
{customDomain.status === 'inactive' ? (
<IconButton
name="edit"
<Flex gap="2">
{customDomain.status === 'inactive' ? (
<Button
variant="whitePrimary"
onClick={() => {
setEditDomain(customDomain);
onOpenCreateModal();
}}
>
{t('common:Edit')}
</Button>
) : (
<Button
variant="whitePrimary"
onClick={() => {
setEditDomain(customDomain);
onOpenDomainVerify();
}}
>
{t('account:custom_domain.domain_verify')}
</Button>
)}
<Button
variant="whiteDanger"
onClick={() => {
setEditDomain(customDomain);
onOpenCreateModal();
return openConfirm(() => onDelete(customDomain.domain))();
}}
/>
) : (
<></>
)}
<IconButton
name="delete"
onClick={() => {
return openConfirm(() => onDelete(customDomain.domain))();
}}
></IconButton>
>
{t('common:Delete')}
</Button>
</Flex>
</Td>
</Tr>
))
@ -136,6 +159,15 @@ const CustomDomain = () => {
data={editDomain!}
/>
)}
{isOpenDomainVerify && editDomain?.domain && (
<DomainVerifyModal
domain={editDomain?.domain}
onClose={() => {
onCloseDomainVerify();
setEditDomain(undefined);
}}
/>
)}
</>
);
};

View File

@ -26,3 +26,13 @@ export const activeCustomDomain = (domain: string) =>
});
// TODO: verify files
export const updateCustomDomainVerifyFile = (props: {
domain: string;
path: string;
content: string;
}) =>
POST<{ success: boolean; message: string }>(
'/proApi/support/customDomain/updateVerifyFile',
props
);