Commit 47134990 by Nixieboluo Committed by GitHub

feat(outlink): new configuration page (#7456)

* feat(outlink): add channel counts

* feat(publish): show channel connection counts

* refactor(outlink): query optimization

Signed-off-by: Nixieboluo <me@sagirii.me>

* feat(outlink): a11y optimizations

Signed-off-by: Nixieboluo <me@sagirii.me>

* test(outlink): fix test failure

Signed-off-by: Nixieboluo <me@sagirii.me>

* fix(outlink): add required

Signed-off-by: Nixieboluo <me@sagirii.me>

* refactor(outlink): address review comments

Signed-off-by: Nixieboluo <me@sagirii.me>

---------

Signed-off-by: Nixieboluo <me@sagirii.me>
parent 549230b0
......@@ -98,6 +98,50 @@ export const OutLinkListResponseSchema = z.array(OutLinkSchema).meta({
export type OutLinkListResponseType = z.infer<typeof OutLinkListResponseSchema>;
/* ============================================================================
* API: 获取发布渠道连接数量
* Route: GET /api/support/outLink/count
* Method: GET
* Description: 获取应用侧栏展示的发布渠道连接数量。
* Tags: ['发布渠道']
* ============================================================================ */
export const OutLinkCountQuerySchema = z.object({
appId: ObjectIdSchema.meta({
example: '68ad85a7463006c963799a05',
description: '应用 ID'
})
});
export type OutLinkCountQueryType = z.infer<typeof OutLinkCountQuerySchema>;
export const OutLinkCountResponseSchema = z.object({
[PublishChannelEnum.share]: z.number().int().nonnegative().meta({
example: 2,
description: '分享链接数量'
}),
[PublishChannelEnum.feishu]: z.number().int().nonnegative().meta({
example: 1,
description: '飞书机器人数量'
}),
[PublishChannelEnum.dingtalk]: z.number().int().nonnegative().meta({
example: 1,
description: '钉钉机器人数量'
}),
[PublishChannelEnum.wecom]: z.number().int().nonnegative().meta({
example: 1,
description: '企业微信机器人数量'
}),
[PublishChannelEnum.wechat]: z.number().int().nonnegative().meta({
example: 1,
description: '微信机器人数量'
}),
[PublishChannelEnum.officialAccount]: z.number().int().nonnegative().meta({
example: 1,
description: '微信公众号数量'
})
});
export type OutLinkCountResponseType = z.infer<typeof OutLinkCountResponseSchema>;
/* ============================================================================
* API: 创建发布渠道
* Route: POST /api/support/outLink/create
* Method: POST
......
......@@ -3,6 +3,8 @@ import { DevApiTagsMap } from '../../tag';
import {
OutLinkCreateBodySchema,
OutLinkCreateResponseSchema,
OutLinkCountQuerySchema,
OutLinkCountResponseSchema,
OutLinkDeleteQuerySchema,
OutLinkDeleteResponseSchema,
OutLinkListQuerySchema,
......@@ -18,6 +20,26 @@ import {
} from './api';
export const OutLinkPath: OpenAPIPath = {
'/support/outLink/count': {
get: {
summary: '获取应用的发布渠道连接数量',
description: '查询侧栏展示的六种发布渠道连接数量',
tags: [DevApiTagsMap.publishChannel],
requestParams: {
query: OutLinkCountQuerySchema
},
responses: {
200: {
description: '成功返回发布渠道连接数量',
content: {
'application/json': {
schema: OutLinkCountResponseSchema
}
}
}
}
}
},
'/support/outLink/list': {
get: {
summary: '获取应用的发布渠道列表',
......
......@@ -113,6 +113,7 @@ const logger = getLogger(LogCategories.INFRA.MONGO);
defineIndex(OutLinkSchema, { key: { shareId: -1 } });
defineIndex(OutLinkSchema, { key: { teamId: 1, tmbId: 1, appId: 1 } });
defineIndex(OutLinkSchema, { key: { teamId: 1, appId: 1, type: 1 } });
// Wechat polling recovery: find online channels on startup
defineIndex(OutLinkSchema, {
key: { type: 1, 'app.status': 1 },
......
......@@ -500,6 +500,7 @@ export const iconPaths = {
'support/config/configFill': () => import('./icons/support/config/configFill.svg'),
'support/config/configLight': () => import('./icons/support/config/configLight.svg'),
'support/outlink/apikeyFill': () => import('./icons/support/outlink/apikeyFill.svg'),
'support/outlink/share': () => import('./icons/support/outlink/share.svg'),
'support/permission/collaborator': () => import('./icons/support/permission/collaborator.svg'),
'support/permission/privateLight': () => import('./icons/support/permission/privateLight.svg'),
'support/permission/publicLight': () => import('./icons/support/permission/publicLight.svg'),
......
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M816 416a174.816 174.816 0 0 1-133.6-62.624l-219.616 104.672a222.944 222.944 0 0 1-1.216 174.656l173.696 89.984A179.84 179.84 0 1 1 611.2 784l-185.056-96a224 224 0 1 1 2.912-284.8l221.44-105.6A175.552 175.552 0 1 1 816 416z" fill="currentColor" />
</svg>
......@@ -18,12 +18,14 @@
"feishu_bot_desc": "Connect to Feishu Bot directly via API",
"key_alias": "Key alias, for display only",
"link_name": "Share Link Name",
"native_channels": "Native Channels",
"new_feishu_bot": "add_new Feishu Bot",
"official_account.create_modal_title": "Create WeChat Official Account Integration",
"official_account.desc": "Connect to WeChat Official Account directly via API",
"official_account.edit_modal_title": "Edit WeChat Official Account Integration",
"official_account.name": "WeChat Official Account Integration",
"official_account.params": "WeChat Official Account Parameters",
"official_account.title": "WeChat Official Account",
"private_config": "Visibility configuration",
"publish_name": "Name",
"qpm_is_empty": "QPM cannot be empty",
......@@ -36,6 +38,7 @@
"token_auth": "Token Authentication",
"token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.",
"token_auth_use_cases": "View Token Authentication Guide",
"third_party_publish_channels": "Third-Party Channels",
"use_default_domain": "Use Default Domain",
"wechat.bot": "WeChat Personal",
"wechat.bot_desc": "Connect to WeChat personal chat via ClawBot",
......
......@@ -18,12 +18,14 @@
"feishu_bot_desc": "通过 API 直接接入飞书机器人",
"key_alias": "key 的别名,仅用于展示",
"link_name": "分享链接的名字",
"native_channels": "原生渠道",
"new_feishu_bot": "新增飞书机器人",
"official_account.create_modal_title": "创建微信公众号接入",
"official_account.desc": "通过 API 直接接入微信公众号",
"official_account.edit_modal_title": "编辑微信公众号接入",
"official_account.name": "微信公众号接入",
"official_account.params": "微信公众号参数",
"official_account.title": "微信公众号",
"private_config": "可见度配置",
"publish_name": "名称",
"qpm_is_empty": "QPM 不能为空",
......@@ -36,6 +38,7 @@
"token_auth": "身份验证",
"token_auth_tips": "身份校验服务器地址",
"token_auth_use_cases": "查看身份验证使用说明",
"third_party_publish_channels": "第三方发布渠道",
"use_default_domain": "使用默认域名",
"wechat.bot": "微信个人号",
"wechat.bot_desc": "通过 ClawBot 接入微信个人号聊天",
......
......@@ -18,12 +18,14 @@
"feishu_bot_desc": "透過 API 直接連結 Lark 聊天機器人",
"key_alias": "金鑰別名,僅供顯示",
"link_name": "分享連結名稱",
"native_channels": "原生渠道",
"new_feishu_bot": "新增 Lark 聊天機器人",
"official_account.create_modal_title": "建立微信公眾號整合",
"official_account.desc": "透過 API 直接連結微信公眾號",
"official_account.edit_modal_title": "編輯微信公眾號整合",
"official_account.name": "微信公眾號整合",
"official_account.params": "微信公眾號參數",
"official_account.title": "微信公眾號",
"private_config": "顯示設定",
"publish_name": "名稱",
"qpm_is_empty": "QPM 不可為空白",
......@@ -36,6 +38,7 @@
"token_auth": "身分驗證",
"token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。",
"token_auth_use_cases": "檢視身分驗證指南",
"third_party_publish_channels": "第三方發布渠道",
"use_default_domain": "使用預設域名",
"wechat.bot": "微信個人號",
"wechat.bot_desc": "透過 ClawBot 連結微信個人號聊天",
......
......@@ -78,6 +78,17 @@ type ApiKeyTableProps = {
appId?: string;
};
const apiKeyTableFields = [
'name',
'apiKey',
'usagePoints',
'expiredTime',
'lastUsedTime',
'createTime',
'actions'
] as const;
type ApiKeyTableField = (typeof apiKeyTableFields)[number];
const isSameTagIds = (left: string[], right: string[]) => {
if (left.length !== right.length) return false;
......@@ -284,6 +295,158 @@ const ApiKeyTable = ({ mode = 'account', appId }: ApiKeyTableProps) => {
}
);
const tableHeaders: Record<ApiKeyTableField, { label: React.ReactNode; width?: string }> = {
name: { label: t('common:Name'), width: '240px' },
apiKey: { label: 'API KEY', width: '130px' },
usagePoints: { label: t('common:support.outlink.Usage points'), width: '150px' },
expiredTime: { label: t('common:expired_time'), width: '120px' },
lastUsedTime: { label: t('account_apikey:last_used_time'), width: '160px' },
createTime: { label: t('account_apikey:create_time'), width: '160px' },
actions: { label: t('common:Action'), width: '92px' }
};
const tableColumns = apiKeyTableFields
.filter((field) => {
if (field === 'usagePoints' || field === 'expiredTime') return hasUsagePlan;
return field !== 'createTime' || !isPublishMode;
})
.map((field) => ({ field, ...tableHeaders[field] }));
const renderTableCell = (field: ApiKeyTableField, item: (typeof apiKeys)[number]) => {
switch (field) {
case 'name':
return (
<Td key={field} maxW={'240px'}>
<Flex flexDirection={'column'} minW={0}>
<MyTooltip label={item.name} showOnlyWhenOverflow>
<Box
maxW={'220px'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{item.name}
</Box>
</MyTooltip>
<ApiKeyTagEditor
key={`${item._id}-${(item.tagIds || []).join(',')}`}
apiKeyId={item._id}
appName={item.appName}
tagIds={item.tagIds || []}
allTags={openApiTags}
onSave={async (apiKeyId, tagIds) => {
await onUpdateApiKeyTags({
apiKeyId,
tagIds
});
}}
onManage={() => setShowTagManage(true)}
onCreateTag={onCreateTagFromSelect}
isLoading={isGettingTags || isUpdatingApiKeyTags}
/>
</Flex>
</Td>
);
case 'apiKey':
return (
<Td key={field} maxW={'130px'}>
<Flex alignItems={'center'} gap={1} role={'group'} minW={0}>
<Box minW={0} overflow={'hidden'} textOverflow={'ellipsis'} whiteSpace={'nowrap'}>
{maskApiKey(item.apiKey)}
</Box>
{item.canCopy && (
<MyIcon
name={copyingApiKeyId === item._id ? 'common/loading' : 'copy'}
w={'15px'}
flexShrink={0}
aria-label={t('common:Copy')}
role={'button'}
tabIndex={0}
color={'myGray.600'}
opacity={copyingApiKeyId === item._id ? 1 : 0}
visibility={copyingApiKeyId === item._id ? 'visible' : 'hidden'}
cursor={'pointer'}
transition={'opacity 0.15s ease, color 0.15s ease'}
_groupHover={{ opacity: 1, visibility: 'visible' }}
_hover={{ color: 'primary.600' }}
onClick={() => onCopyApiKey(item._id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCopyApiKey(item._id);
}
}}
/>
)}
</Flex>
</Td>
);
case 'usagePoints':
return (
<Td key={field} whiteSpace={'nowrap'}>
{Math.round(item.usagePoints)}/
{item.limit?.maxUsagePoints && item.limit.maxUsagePoints > -1
? `${item.limit.maxUsagePoints}`
: t('common:Unlimited')}
</Td>
);
case 'expiredTime':
return (
<Td key={field} whiteSpace={'pre-wrap'}>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
);
case 'lastUsedTime':
return (
<Td key={field} whiteSpace={'normal'}>
{item.lastUsedTime
? dayjs(item.lastUsedTime).format('YYYY/MM/DD HH:mm:ss')
: t('common:un_used')}
</Td>
);
case 'createTime':
return (
<Td key={field} whiteSpace={'normal'}>
{dayjs(item.createTime).format('YYYY/MM/DD HH:mm:ss')}
</Td>
);
case 'actions':
return (
<Td key={field} w={'92px'}>
<Flex alignItems={'center'} gap={2}>
<MyTooltip label={t('common:Edit')}>
<IconButton
icon={<MyIcon name={'edit'} w={4} />}
variant={'whitePrimary'}
size={'sm'}
aria-label={t('common:Edit')}
onClick={() =>
setEditData({
_id: item._id,
name: item.name,
limit: item.limit,
authProxy: item.authProxy,
tags: item.tagIds || []
})
}
/>
</MyTooltip>
<MyTooltip label={t('common:Delete')}>
<IconButton
icon={<MyIcon name={'delete'} w={4} />}
variant={'whiteDanger'}
size={'sm'}
aria-label={t('common:Delete')}
onClick={() => openConfirm({ onConfirm: () => onclickRemove(item._id) })()}
/>
</MyTooltip>
</Flex>
</Td>
);
}
};
return (
<MyBox
isLoading={isGetting}
......@@ -291,14 +454,17 @@ const ApiKeyTable = ({ mode = 'account', appId }: ApiKeyTableProps) => {
flexDirection={'column'}
h={'100%'}
position={'relative'}
pt={0}
px={0}
p={isPublishMode ? 6 : 0}
minH={isPublishMode ? '50vh' : undefined}
>
<Flex flexDirection={'column'} alignItems={'stretch'} gap={3}>
<Flex minW={0} alignItems={'center'}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
{t('common:support.openapi.Api manager')}({apiKeys.length})
<Box
color={isPublishMode ? 'myGray.900' : undefined}
fontWeight={isPublishMode ? 'medium' : 'bold'}
fontSize={isPublishMode ? 'lg' : ['md', 'lg']}
>
{`${t('common:support.openapi.Api manager')}(${apiKeys.length})`}
</Box>
{feConfigs?.docUrl && (
<Link
......@@ -316,7 +482,7 @@ const ApiKeyTable = ({ mode = 'account', appId }: ApiKeyTableProps) => {
)}
</Flex>
<Flex
alignItems={['stretch', 'center']}
alignItems={['stretch', 'flex-end']}
justifyContent={'space-between'}
gap={3}
minW={0}
......@@ -435,158 +601,19 @@ const ApiKeyTable = ({ mode = 'account', appId }: ApiKeyTableProps) => {
<Table sx={{ tableLayout: 'fixed' }}>
<Thead>
<Tr>
<Th w={'240px'}>{t('common:Name')}</Th>
<Th w={'130px'}>API KEY</Th>
{hasUsagePlan && <Th w={'150px'}>{t('common:support.outlink.Usage points')}</Th>}
{hasUsagePlan && (
<>
<Th w={'120px'}>{t('common:expired_time')}</Th>
</>
)}
<Th w={'160px'}>{t('account_apikey:last_used_time')}</Th>
<Th w={'160px'}>{t('account_apikey:create_time')}</Th>
<Th w={'92px'} />
{tableColumns.map(({ field, width, label }) => (
<Th key={field} w={width}>
{label}
</Th>
))}
</Tr>
</Thead>
<Tbody fontSize={'sm'}>
{apiKeys.map(
({
_id,
name,
limit,
usagePoints,
apiKey,
canCopy,
createTime,
lastUsedTime,
authProxy,
appName,
tagIds
}) => (
<Tr key={_id}>
<Td maxW={'240px'}>
<Flex flexDirection={'column'} minW={0}>
<MyTooltip label={name} showOnlyWhenOverflow>
<Box
maxW={'220px'}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{name}
</Box>
</MyTooltip>
<ApiKeyTagEditor
key={`${_id}-${(tagIds || []).join(',')}`}
apiKeyId={_id}
appName={appName}
tagIds={tagIds || []}
allTags={openApiTags}
onSave={async (apiKeyId, tagIds) => {
await onUpdateApiKeyTags({
apiKeyId,
tagIds
});
}}
onManage={() => setShowTagManage(true)}
onCreateTag={onCreateTagFromSelect}
isLoading={isGettingTags || isUpdatingApiKeyTags}
/>
</Flex>
</Td>
<Td maxW={'130px'}>
<Flex alignItems={'center'} gap={1} role={'group'} minW={0}>
<Box
minW={0}
overflow={'hidden'}
textOverflow={'ellipsis'}
whiteSpace={'nowrap'}
>
{maskApiKey(apiKey)}
</Box>
{canCopy && (
<MyIcon
name={copyingApiKeyId === _id ? 'common/loading' : 'copy'}
w={'15px'}
flexShrink={0}
aria-label={t('common:Copy')}
role={'button'}
tabIndex={0}
color={'myGray.600'}
opacity={copyingApiKeyId === _id ? 1 : 0}
visibility={copyingApiKeyId === _id ? 'visible' : 'hidden'}
cursor={'pointer'}
transition={'opacity 0.15s ease, color 0.15s ease'}
_groupHover={{ opacity: 1, visibility: 'visible' }}
_hover={{ color: 'primary.600' }}
onClick={() => onCopyApiKey(_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCopyApiKey(_id);
}
}}
/>
)}
</Flex>
</Td>
{hasUsagePlan && (
<Td whiteSpace={'nowrap'}>
{Math.round(usagePoints)}/
{limit?.maxUsagePoints && limit?.maxUsagePoints > -1
? `${limit?.maxUsagePoints}`
: t('common:Unlimited')}
</Td>
)}
{hasUsagePlan && (
<>
<Td whiteSpace={'pre-wrap'}>
{limit?.expiredTime
? dayjs(limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
</>
)}
<Td whiteSpace={'normal'}>
{lastUsedTime
? dayjs(lastUsedTime).format('YYYY/MM/DD HH:mm:ss')
: t('common:un_used')}
</Td>
<Td whiteSpace={'normal'}>{dayjs(createTime).format('YYYY/MM/DD HH:mm:ss')}</Td>
<Td w={'92px'}>
<Flex alignItems={'center'} gap={2}>
<MyTooltip label={t('common:Edit')}>
<IconButton
icon={<MyIcon name={'edit'} w={4} />}
variant={'whitePrimary'}
size={'sm'}
aria-label={t('common:Edit')}
onClick={() =>
setEditData({
_id,
name,
limit,
authProxy,
tags: tagIds || []
})
}
/>
</MyTooltip>
<MyTooltip label={t('common:Delete')}>
<IconButton
icon={<MyIcon name={'delete'} w={4} />}
variant={'whiteDanger'}
size={'sm'}
aria-label={t('common:Delete')}
onClick={() => openConfirm({ onConfirm: () => onclickRemove(_id) })()}
/>
</MyTooltip>
</Flex>
</Td>
</Tr>
)
)}
{apiKeys.map((item) => (
<Tr key={item._id}>
{tableColumns.map(({ field }) => renderTableCell(field, item))}
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
......@@ -733,7 +760,9 @@ function EditKeyModal({
>
<Flex flexDirection={'column'} gap={4}>
<Flex alignItems={'center'} gap={4}>
<FormLabel flex={'0 0 90px'}>{t('common:Name')}</FormLabel>
<FormLabel flex={'0 0 90px'} required>
{t('common:Name')}
</FormLabel>
<Input
placeholder={t('publish:key_alias') || 'key_alias'}
maxLength={50}
......
......@@ -64,7 +64,15 @@ const AgentEdit = () => {
);
return (
<Flex h={'100%'} minH={0} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}>
<Flex
h={'100%'}
minH={0}
flexDirection={'column'}
px={[3, 0]}
pr={[3, 3]}
borderLeft={currentTab === TabEnum.publish ? '1px solid' : undefined}
borderColor={'myGray.200'}
>
<Header
appForm={appForm}
forbiddenSaveSnapshot={forbiddenSaveSnapshot}
......
......@@ -200,7 +200,7 @@ const Header = ({
</Flex>
)}
<Flex w={'full'} alignItems={'center'} position={'relative'} h={'full'}>
<Box flex={'1'}>
<Box flex={'1'} ml={'16px'}>
<FolderPath
rootName={t('common:All')}
paths={paths}
......
......@@ -61,7 +61,15 @@ const SimpleEdit = () => {
);
return (
<Flex h={'100%'} minH={0} flexDirection={'column'} px={[3, 0]} pr={[3, 3]}>
<Flex
h={'100%'}
minH={0}
flexDirection={'column'}
px={[3, 0]}
pr={[3, 3]}
borderLeft={currentTab === TabEnum.publish ? '1px solid' : undefined}
borderColor={'myGray.200'}
>
<Header
appForm={appForm}
forbiddenSaveSnapshot={forbiddenSaveSnapshot}
......
......@@ -33,7 +33,13 @@ import { getDocPath } from '@/web/common/system/doc';
const DingTalkEditModal = dynamic(() => import('./DingTalkEditModal'));
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
const DingTalk = ({ appId }: { appId: string }) => {
const DingTalk = ({
appId,
onRefreshOutLinkCounts
}: {
appId: string;
onRefreshOutLinkCounts: () => Promise<unknown>;
}) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
......@@ -66,10 +72,10 @@ const DingTalk = ({ appId }: { appId: string }) => {
const [showShareLink, setShowShareLink] = useState<string | null>(null);
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Box position={'relative'} p={6} minH={'50vh'}>
<Flex justifyContent={'space-between'} flexDirection="row">
<HStack>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'lg'}>
{t('publish:dingtalk.title')}
</Box>
{feConfigs?.docUrl && (
......@@ -112,14 +118,9 @@ const DingTalk = ({ appId }: { appId: string }) => {
<Tr>
<Th>{t('common:Name')}</Th>
<Th>{t('common:support.outlink.Usage points')}</Th>
{feConfigs?.isPlus && (
<>
<Th>{t('common:core.app.share.Ip limit title')}</Th>
<Th>{t('common:expired_time')}</Th>
</>
)}
{feConfigs?.isPlus && <Th>{t('common:expired_time')}</Th>}
<Th>{t('common:last_use_time')}</Th>
<Th></Th>
<Th>{t('common:Action')}</Th>
</Tr>
</Thead>
<Tbody>
......@@ -137,14 +138,11 @@ const DingTalk = ({ appId }: { appId: string }) => {
: ''}
</Td>
{feConfigs?.isPlus && (
<>
<Td>{item?.limit?.QPM || '-'}</Td>
<Td>
{item?.limit?.expiredTime
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
</>
<Td>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
)}
<Td>
{item.lastTime
......@@ -200,7 +198,10 @@ const DingTalk = ({ appId }: { appId: string }) => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
void Promise.all([
refetchShareChatList(),
onRefreshOutLinkCounts()
]);
} catch (error) {
console.log(error);
}
......@@ -221,7 +222,10 @@ const DingTalk = ({ appId }: { appId: string }) => {
<DingTalkEditModal
appId={appId}
defaultData={editDingTalkLinkData}
onCreate={() => Promise.all([refetchShareChatList(), setEditDingTalkLinkData(undefined)])}
onCreate={() => {
void Promise.all([refetchShareChatList(), onRefreshOutLinkCounts()]);
setEditDingTalkLinkData(undefined);
}}
onEdit={() => Promise.all([refetchShareChatList(), setEditDingTalkLinkData(undefined)])}
onClose={() => setEditDingTalkLinkData(undefined)}
isEdit={isEdit}
......
......@@ -33,7 +33,13 @@ import { getDocPath } from '@/web/common/system/doc';
const FeiShuEditModal = dynamic(() => import('./FeiShuEditModal'));
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
const FeiShu = ({ appId }: { appId: string }) => {
const FeiShu = ({
appId,
onRefreshOutLinkCounts
}: {
appId: string;
onRefreshOutLinkCounts: () => Promise<unknown>;
}) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
......@@ -65,10 +71,10 @@ const FeiShu = ({ appId }: { appId: string }) => {
const [showShareLink, setShowShareLink] = useState<string | null>(null);
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Box position={'relative'} p={6} minH={'50vh'}>
<Flex justifyContent={'space-between'} flexDirection="row">
<HStack>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'lg'}>
{t('common:core.app.publish.Fei shu bot publish')}
</Box>
{feConfigs?.docUrl && (
......@@ -111,14 +117,9 @@ const FeiShu = ({ appId }: { appId: string }) => {
<Tr>
<Th>{t('common:Name')}</Th>
<Th>{t('common:support.outlink.Usage points')}</Th>
{feConfigs?.isPlus && (
<>
<Th>{t('common:core.app.share.Ip limit title')}</Th>
<Th>{t('common:expired_time')}</Th>
</>
)}
{feConfigs?.isPlus && <Th>{t('common:expired_time')}</Th>}
<Th>{t('common:last_use_time')}</Th>
<Th></Th>
<Th>{t('common:Action')}</Th>
</Tr>
</Thead>
<Tbody>
......@@ -136,14 +137,11 @@ const FeiShu = ({ appId }: { appId: string }) => {
: ''}
</Td>
{feConfigs?.isPlus && (
<>
<Td>{item?.limit?.QPM || '-'}</Td>
<Td>
{item?.limit?.expiredTime
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
</>
<Td>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
)}
<Td>
{item.lastTime
......@@ -199,7 +197,10 @@ const FeiShu = ({ appId }: { appId: string }) => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
void Promise.all([
refetchShareChatList(),
onRefreshOutLinkCounts()
]);
} catch (error) {
console.log(error);
}
......@@ -220,7 +221,10 @@ const FeiShu = ({ appId }: { appId: string }) => {
<FeiShuEditModal
appId={appId}
defaultData={editFeiShuLinkData}
onCreate={() => Promise.all([refetchShareChatList(), setEditFeiShuLinkData(undefined)])}
onCreate={() => {
void Promise.all([refetchShareChatList(), onRefreshOutLinkCounts()]);
setEditFeiShuLinkData(undefined);
}}
onEdit={() => Promise.all([refetchShareChatList(), setEditFeiShuLinkData(undefined)])}
onClose={() => setEditFeiShuLinkData(undefined)}
isEdit={isEdit}
......
......@@ -50,7 +50,14 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest';
const SelectUsingWayModal = dynamic(() => import('./SelectUsingWayModal'));
const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
const Share = ({
appId,
onRefreshOutLinkCounts
}: {
appId: string;
type: PublishChannelEnum;
onRefreshOutLinkCounts: () => Promise<unknown>;
}) => {
const { t } = useTranslation();
const { setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
......@@ -72,13 +79,13 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
);
return (
<MyBox h={'100%'} isLoading={isFetching} position={'relative'} pt={3} px={5} minH={'50vh'}>
<MyBox h={'100%'} isLoading={isFetching} position={'relative'} p={6} minH={'50vh'}>
<Flex justifyContent={'space-between'} flexDirection="row">
<HStack>
<Box>
<Flex alignItems={'center'}>
<Box color={'myGray.900'} fontWeight={'bold'} fontSize={['md', 'lg']}>
{t('common:core.app.Share link')}
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'lg'}>
{t('common:share_link')}
</Box>
{feConfigs?.docUrl && (
<Link
......@@ -95,7 +102,7 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
</Link>
)}
</Flex>
<Box fontSize={'mini'} color={'myGray.600'}>
<Box fontSize={'mini'} fontWeight={'normal'} color={'myGray.600'}>
{t('common:core.app.Share link desc detail')}
</Box>
</Box>
......@@ -121,36 +128,16 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
<Thead>
<Tr>
<Th>{t('common:Name')}</Th>
{feConfigs?.isPlus && (
<>
<Th>{t('common:expired_time')}</Th>
</>
)}
<Th>{t('common:support.outlink.Usage points')}</Th>
<Th>{t('common:core.app.share.Is response quote')}</Th>
{feConfigs?.isPlus && (
<>
<Th>{t('common:core.app.share.Ip limit title')}</Th>
<Th>{t('common:core.app.share.Role check')}</Th>
</>
)}
{feConfigs?.isPlus && <Th>{t('common:expired_time')}</Th>}
<Th>{t('common:last_use_time')}</Th>
<Th></Th>
<Th>{t('common:Action')}</Th>
</Tr>
</Thead>
<Tbody>
{shareChatList.map((item) => (
<Tr key={item._id}>
<Td>{item.name}</Td>
{feConfigs?.isPlus && (
<>
<Td>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY-MM-DD HH:mm')
: '-'}
</Td>
</>
)}
<Td>
{Math.round(item.usagePoints)}
{feConfigs?.isPlus
......@@ -161,13 +148,12 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
}`
: ''}
</Td>
<Td>{item.showCite ? '✔' : '✖'}</Td>
{feConfigs?.isPlus && (
<>
<Td>{item?.limit?.QPM || '-'}</Td>
<Th>{item?.limit?.hookUrl ? '✔' : '✖'}</Th>
</>
<Td>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY-MM-DD HH:mm')
: '-'}
</Td>
)}
<Td>
{item.lastTime
......@@ -221,7 +207,10 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
void Promise.all([
refetchShareChatList(),
onRefreshOutLinkCounts()
]);
} catch (error) {
console.log(error);
}
......@@ -251,7 +240,7 @@ const Share = ({ appId }: { appId: string; type: PublishChannelEnum }) => {
onCreate={(id) => {
const url = `${location.origin}/chat/share?shareId=${id}`;
copyData(url, t('common:core.app.share.Create link tip'));
refetchShareChatList();
void Promise.all([refetchShareChatList(), onRefreshOutLinkCounts()]);
setEditLinkData(undefined);
}}
onEdit={() => {
......@@ -348,7 +337,9 @@ function EditLinkModal({
{t('publish:basic_info')}
</Box>
<Flex alignItems={'center'} mt={4}>
<FormLabel flex={'0 0 90px'}>{t('common:Name')}</FormLabel>
<FormLabel flex={'0 0 90px'} required>
{t('common:Name')}
</FormLabel>
<Input
placeholder={t('publish:link_name')}
maxLength={100}
......
......@@ -33,7 +33,13 @@ import { getDocPath } from '@/web/common/system/doc';
const OffiAccountEditModal = dynamic(() => import('./OffiAccountEditModal'));
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
const OffiAccount = ({ appId }: { appId: string }) => {
const OffiAccount = ({
appId,
onRefreshOutLinkCounts
}: {
appId: string;
onRefreshOutLinkCounts: () => Promise<unknown>;
}) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
......@@ -66,11 +72,11 @@ const OffiAccount = ({ appId }: { appId: string }) => {
const [showShareLink, setShowShareLink] = useState<string | null>(null);
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Box position={'relative'} p={6} minH={'50vh'}>
<Flex justifyContent={'space-between'} flexDirection="row">
<HStack>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
{t('publish:official_account.name')}
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'lg'}>
{t('publish:official_account.title')}
</Box>
{feConfigs?.docUrl && (
......@@ -114,14 +120,9 @@ const OffiAccount = ({ appId }: { appId: string }) => {
<Tr>
<Th>{t('common:Name')} </Th>
<Th> {t('common:support.outlink.Usage points')} </Th>
{feConfigs?.isPlus && (
<>
<Th>{t('common:core.app.share.Ip limit title')} </Th>
<Th> {t('common:expired_time')} </Th>
</>
)}
{feConfigs?.isPlus && <Th>{t('common:expired_time')} </Th>}
<Th>{t('common:last_use_time')} </Th>
<Th> </Th>
<Th>{t('common:Action')} </Th>
</Tr>
</Thead>
<Tbody>
......@@ -139,14 +140,11 @@ const OffiAccount = ({ appId }: { appId: string }) => {
: ''}
</Td>
{feConfigs?.isPlus && (
<>
<Td>{item?.limit?.QPM || '-'} </Td>
<Td>
{item?.limit?.expiredTime
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
</>
<Td>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
)}
<Td>
{item.lastTime
......@@ -202,7 +200,10 @@ const OffiAccount = ({ appId }: { appId: string }) => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
void Promise.all([
refetchShareChatList(),
onRefreshOutLinkCounts()
]);
} catch (error) {
console.log(error);
}
......@@ -223,7 +224,10 @@ const OffiAccount = ({ appId }: { appId: string }) => {
<OffiAccountEditModal
appId={appId}
defaultData={editOffiAccountData}
onCreate={() => Promise.all([refetchShareChatList(), setEditOffiAccountData(undefined)])}
onCreate={() => {
void Promise.all([refetchShareChatList(), onRefreshOutLinkCounts()]);
setEditOffiAccountData(undefined);
}}
onEdit={() => Promise.all([refetchShareChatList(), setEditOffiAccountData(undefined)])}
onClose={() => setEditOffiAccountData(undefined)}
isEdit={isEdit}
......
......@@ -82,8 +82,8 @@ const PlaygroundVisibilityConfig = ({ appId }: { appId: string }) => {
};
return (
<Flex flexDirection="column">
<Box fontSize={'sm'} fontWeight={'medium'} color={'myGray.900'} mb={3}>
<Flex flexDirection="column" p={6}>
<Box fontSize={'lg'} fontWeight={'medium'} color={'myGray.900'} mb={3}>
{t('app:publish.playground_link')}
</Box>
......
......@@ -33,7 +33,13 @@ import MyTag from '@fastgpt/web/components/common/Tag/index';
const WechatEditModal = dynamic(() => import('./WechatEditModal'));
const QRLoginModal = dynamic(() => import('./QRLoginModal'));
const Wechat = ({ appId }: { appId: string }) => {
const Wechat = ({
appId,
onRefreshOutLinkCounts
}: {
appId: string;
onRefreshOutLinkCounts: () => Promise<unknown>;
}) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
......@@ -64,10 +70,10 @@ const Wechat = ({ appId }: { appId: string }) => {
};
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Box position={'relative'} p={6} minH={'50vh'}>
<Flex justifyContent={'space-between'}>
<Flex alignItems={'center'}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'lg'}>
{t('publish:wechat.title')}
</Box>
{feConfigs?.docUrl && (
......@@ -109,7 +115,7 @@ const Wechat = ({ appId }: { appId: string }) => {
<Th>{t('publish:wechat.status')}</Th>
<Th>{t('common:support.outlink.Usage points')}</Th>
<Th>{t('common:last_use_time')}</Th>
<Th />
<Th>{t('common:Action')}</Th>
</Tr>
</Thead>
<Tbody>
......@@ -197,7 +203,7 @@ const Wechat = ({ appId }: { appId: string }) => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetch();
void Promise.all([refetch(), onRefreshOutLinkCounts()]);
} finally {
setIsLoading(false);
}
......@@ -224,7 +230,7 @@ const Wechat = ({ appId }: { appId: string }) => {
defaultData={editData}
isEdit={isEdit}
onCreate={async (shareId) => {
const newList = await refetch();
const [newList] = await Promise.all([refetch(), onRefreshOutLinkCounts()]);
return newList?.find((i) => i.shareId === shareId)?._id;
}}
onEdit={() => refetch()}
......
......@@ -34,7 +34,13 @@ import { i18nT } from '@fastgpt/global/common/i18n/utils';
const WecomEditModal = dynamic(() => import('./WecomEditModal'));
const ShowShareLinkModal = dynamic(() => import('../components/showShareLinkModal'));
const Wecom = ({ appId }: { appId: string }) => {
const Wecom = ({
appId,
onRefreshOutLinkCounts
}: {
appId: string;
onRefreshOutLinkCounts: () => Promise<unknown>;
}) => {
const { t } = useTranslation();
const { Loading, setIsLoading } = useLoading();
const { feConfigs } = useSystemStore();
......@@ -68,10 +74,10 @@ const Wecom = ({ appId }: { appId: string }) => {
});
return (
<Box position={'relative'} pt={3} px={5} minH={'50vh'}>
<Box position={'relative'} p={6} minH={'50vh'}>
<Flex justifyContent={'space-between'} flexDirection="row">
<Flex alignItems={'center'}>
<Box fontWeight={'bold'} fontSize={['md', 'lg']}>
<Box color={'myGray.900'} fontWeight={'medium'} fontSize={'lg'}>
{t('publish:wecom.title')}
</Box>
{feConfigs?.docUrl && (
......@@ -127,14 +133,9 @@ const Wecom = ({ appId }: { appId: string }) => {
<Tr>
<Th>{t('common:Name')} </Th>
<Th> {t('common:support.outlink.Usage points')} </Th>
{feConfigs?.isPlus && (
<>
<Th>{t('common:core.app.share.Ip limit title')} </Th>
<Th> {t('common:expired_time')} </Th>
</>
)}
{feConfigs?.isPlus && <Th>{t('common:expired_time')} </Th>}
<Th>{t('common:last_use_time')} </Th>
<Th> </Th>
<Th>{t('common:Action')} </Th>
</Tr>
</Thead>
<Tbody>
......@@ -152,14 +153,11 @@ const Wecom = ({ appId }: { appId: string }) => {
: ''}
</Td>
{feConfigs?.isPlus && (
<>
<Td>{item?.limit?.QPM || '-'} </Td>
<Td>
{item?.limit?.expiredTime
? dayjs(item.limit?.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
</>
<Td>
{item.limit?.expiredTime
? dayjs(item.limit.expiredTime).format('YYYY/MM/DD\nHH:mm')
: '-'}
</Td>
)}
<Td>
{item.lastTime
......@@ -215,7 +213,10 @@ const Wecom = ({ appId }: { appId: string }) => {
setIsLoading(true);
try {
await delShareChatById(item._id);
refetchShareChatList();
void Promise.all([
refetchShareChatList(),
onRefreshOutLinkCounts()
]);
} catch (error) {
console.log(error);
}
......@@ -237,7 +238,7 @@ const Wecom = ({ appId }: { appId: string }) => {
appId={appId}
defaultData={editWecomData}
onCreate={async (shareId: string) => {
const newList = await refetchShareChatList();
const [newList] = await Promise.all([refetchShareChatList(), onRefreshOutLinkCounts()]);
const newItem = newList.find((item) => item.shareId === shareId);
return newItem?._id;
}}
......
import React, { useMemo, useState } from 'react';
import { Box, HStack } from '@chakra-ui/react';
import { Box, Button, Flex, HStack } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import type { OutLinkCountResponseType } from '@fastgpt/global/openapi/support/outLink/api';
import dynamic from 'next/dynamic';
import MyRadio from '@/components/common/MyRadio';
import { useTranslation } from 'next-i18next';
import { useContextSelector } from 'use-context-selector';
......@@ -13,6 +14,9 @@ import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useUserStore } from '@/web/support/user/useUserStore';
import { UserTagsSchema } from '@fastgpt/global/support/user/type';
import ProTag from '@/components/ProTip/Tag';
import Avatar from '@fastgpt/web/components/common/Avatar';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { getOutLinkCounts } from '@/web/support/outLink/api';
const Link = dynamic(() => import('./Link'));
const API = dynamic(() => import('./API'));
......@@ -23,6 +27,15 @@ const OffiAccount = dynamic(() => import('./OffiAccount'));
const Wechat = dynamic(() => import('./Wechat'));
const Playground = dynamic(() => import('./Playground'));
type PublishListItem = {
icon: string;
title: React.ReactNode;
value: PublishChannelEnum;
isProFn: boolean;
group: 'native' | 'thirdParty';
countKey?: keyof OutLinkCountResponseType;
};
const OutLink = () => {
const { t } = useTranslation();
const { feConfigs, setShowProModal } = useSystemStore();
......@@ -30,34 +43,41 @@ const OutLink = () => {
const appId = useContextSelector(AppContext, (v) => v.appId);
const isPro = !!feConfigs.isPlus;
const { data: outLinkCounts, refetch: refetchOutLinkCounts } = useQuery(
['outLinkCounts', appId],
() => getOutLinkCounts({ appId }),
{ enabled: !!appId }
);
const publishList = useMemo(
const publishList = useMemo<PublishListItem[]>(
() => [
{
icon: '/imgs/modal/shareFill.svg',
icon: 'support/outlink/share',
title: t('common:core.app.Share link'),
desc: t('common:core.app.Share link desc'),
value: PublishChannelEnum.share,
isProFn: false
isProFn: false,
group: 'native',
countKey: PublishChannelEnum.share
},
{
icon: 'support/outlink/apikeyFill',
title: t('common:core.app.Api request'),
desc: t('common:core.app.Api request desc'),
value: PublishChannelEnum.apikey,
isProFn: false
isProFn: false,
group: 'native'
},
{
icon: 'core/chat/sidebar/home',
title: (
<HStack gap={1}>
<Box>{t('common:navbar.Chat')}</Box>
{!isPro && <ProTag />}
</HStack>
),
value: PublishChannelEnum.playground,
isProFn: true,
group: 'native'
},
...(feConfigs?.show_publish_wechat !== false
? [
{
icon: 'core/app/publish/wechat',
title: t('publish:wechat.bot'),
desc: t('publish:wechat.bot_desc'),
value: PublishChannelEnum.wechat,
isProFn: false
}
]
: []),
...(feConfigs?.show_publish_feishu !== false &&
!userInfo?.tags?.includes(UserTagsSchema.enum.wecom)
? [
......@@ -69,9 +89,10 @@ const OutLink = () => {
{!isPro && <ProTag />}
</HStack>
),
desc: t('publish:feishu_bot_desc'),
value: PublishChannelEnum.feishu,
isProFn: true
isProFn: true,
group: 'thirdParty' as const,
countKey: PublishChannelEnum.feishu as PublishChannelEnum.feishu
}
]
: []),
......@@ -86,9 +107,10 @@ const OutLink = () => {
{!isPro && <ProTag />}
</HStack>
),
desc: t('publish:dingtalk.bot_desc'),
value: PublishChannelEnum.dingtalk,
isProFn: true
isProFn: true,
group: 'thirdParty' as const,
countKey: PublishChannelEnum.dingtalk as PublishChannelEnum.dingtalk
}
]
: []),
......@@ -102,9 +124,22 @@ const OutLink = () => {
{!isPro && <ProTag />}
</HStack>
),
desc: t('publish:wecom.bot_desc'),
value: PublishChannelEnum.wecom,
isProFn: true
isProFn: true,
group: 'thirdParty' as const,
countKey: PublishChannelEnum.wecom as PublishChannelEnum.wecom
}
]
: []),
...(feConfigs?.show_publish_wechat !== false
? [
{
icon: 'core/app/publish/wechat',
title: t('publish:wechat.bot'),
value: PublishChannelEnum.wechat,
isProFn: false,
group: 'thirdParty' as const,
countKey: PublishChannelEnum.wechat as PublishChannelEnum.wechat
}
]
: []),
......@@ -118,70 +153,195 @@ const OutLink = () => {
{!isPro && <ProTag />}
</HStack>
),
desc: t('publish:official_account.desc'),
value: PublishChannelEnum.officialAccount,
isProFn: true
isProFn: true,
group: 'thirdParty' as const,
countKey: PublishChannelEnum.officialAccount as PublishChannelEnum.officialAccount
}
]
: []),
{
icon: 'core/chat/sidebar/home',
title: (
<HStack gap={1}>
<Box>{t('common:navbar.Chat')}</Box>
{!isPro && <ProTag />}
</HStack>
),
desc: t('app:publish.chat_desc'),
value: PublishChannelEnum.playground,
isProFn: true
}
: [])
],
[t, feConfigs, isPro, userInfo?.tags]
);
const [linkType, setLinkType] = useState<PublishChannelEnum>(PublishChannelEnum.share);
const [isNativeChannelOpen, setIsNativeChannelOpen] = useState(true);
const [isThirdPartyChannelOpen, setIsThirdPartyChannelOpen] = useState(true);
return (
<Box>
<Box mx={[4, 8]} py={[4, 6]} borderBottom={'1px solid'} borderColor={'myGray.150'}>
<MyRadio
gridTemplateColumns={[
'repeat(1,1fr)',
'repeat(2, 1fr)',
'repeat(3, 1fr)',
'repeat(3, 1fr)',
'repeat(4, 1fr)'
]}
iconSize={'20px'}
gridGap={[2, 3]}
list={publishList}
value={linkType}
onChange={(e) => {
const config = publishList.find((v) => v.value === e)!;
if (!feConfigs.isPlus && config.isProFn) {
setShowProModal(true);
} else {
setLinkType(e as PublishChannelEnum);
<Flex h={'full'} borderTop={'1px solid'} borderColor={'myGray.200'}>
<Box
w={'220px'}
h={'full'}
borderRight={'1px solid'}
borderColor={'myGray.200'}
pt={4}
pb={2.5}
userSelect={'none'}
>
<Box px={2.5}>
{[
{
name: t('publish:native_channels'),
group: 'native',
isOpen: isNativeChannelOpen,
onClick: () => setIsNativeChannelOpen((state) => !state)
},
{
name: t('publish:third_party_publish_channels'),
group: 'thirdParty',
isOpen: isThirdPartyChannelOpen,
onClick: () => setIsThirdPartyChannelOpen((state) => !state)
}
}}
/>
].map(({ name, group, isOpen, onClick }) => {
const items = publishList
.filter((item) => item.group === group)
.sort((a, b) => {
if (group !== 'thirdParty' || !outLinkCounts) return 0;
return (
(b.countKey ? outLinkCounts[b.countKey] : 0) -
(a.countKey ? outLinkCounts[a.countKey] : 0)
);
});
if (items.length === 0) return null;
return (
<Box key={group}>
<Button
variant={'unstyled'}
display={'flex'}
w={'full'}
h={'auto'}
py={2}
pl={2}
pr={2}
fontSize={'sm'}
fontWeight={500}
rounded={'md'}
color={'myGray.700'}
cursor={'pointer'}
mb={0.5}
_hover={{
bg: 'rgba(17, 24, 36, 0.05)'
}}
justifyContent={'space-between'}
alignItems={'center'}
onClick={onClick}
aria-expanded={isOpen}
_focusVisible={{
bg: 'rgba(17, 24, 36, 0.05)'
}}
>
<Box minW={0} fontWeight={'medium'} textAlign={'left'} whiteSpace={'normal'}>
{name}
</Box>
<MyIcon
name={'core/chat/chevronDown'}
w={'1rem'}
transform={isOpen ? undefined : 'rotate(-90deg)'}
/>
</Button>
{isOpen &&
items.map((item) => (
<Button
key={item.value}
variant={'unstyled'}
display={'flex'}
w={'full'}
h={'auto'}
fontSize={'sm'}
fontWeight={500}
rounded={'md'}
py={2}
pl={'30px'}
pr={2}
cursor={'pointer'}
mb={0.5}
justifyContent={'space-between'}
{...(linkType === item.value
? {
bg: 'primary.50',
color: 'primary.600',
_hover: { bg: 'primary.50' },
_focusVisible: { bg: 'primary.50' }
}
: {
bg: 'transparent',
color: 'myGray.500',
_hover: {
bg: 'rgba(17, 24, 36, 0.05)'
},
_focusVisible: {
bg: 'rgba(17, 24, 36, 0.05)'
}
})}
onClick={() => {
if (!feConfigs.isPlus && item.isProFn) {
setShowProModal(true);
} else {
setLinkType(item.value);
}
}}
aria-pressed={linkType === item.value}
alignItems={'center'}
>
<Avatar src={item.icon} w={'1rem'} mr={1} />
<Box flex={1} minW={0} textAlign={'left'} whiteSpace={'normal'}>
{item.title}
</Box>
{item.countKey && outLinkCounts && outLinkCounts[item.countKey] > 0 && (
<Box
ml={'auto'}
bg={'rgba(17, 24, 36, 0.15)'}
color={'white'}
w={4}
h={4}
borderRadius={'full'}
textAlign={'center'}
lineHeight={4}
fontWeight={500}
fontSize={11}
flexShrink={0}
>
{outLinkCounts[item.countKey]}
</Box>
)}
</Button>
))}
</Box>
);
})}
</Box>
</Box>
<Box mt={2} px={[4, 8]} py={[4, 6]}>
<Box flex={1} minW={0} overflowY={'auto'}>
{linkType === PublishChannelEnum.share && (
<Link appId={appId} type={PublishChannelEnum.share} />
<Link
appId={appId}
type={PublishChannelEnum.share}
onRefreshOutLinkCounts={refetchOutLinkCounts}
/>
)}
{linkType === PublishChannelEnum.apikey && <API appId={appId} />}
{linkType === PublishChannelEnum.feishu && <FeiShu appId={appId} />}
{linkType === PublishChannelEnum.dingtalk && <DingTalk appId={appId} />}
{linkType === PublishChannelEnum.wecom && <Wecom appId={appId} />}
{linkType === PublishChannelEnum.officialAccount && <OffiAccount appId={appId} />}
{linkType === PublishChannelEnum.wechat && <Wechat appId={appId} />}
{linkType === PublishChannelEnum.feishu && (
<FeiShu appId={appId} onRefreshOutLinkCounts={refetchOutLinkCounts} />
)}
{linkType === PublishChannelEnum.dingtalk && (
<DingTalk appId={appId} onRefreshOutLinkCounts={refetchOutLinkCounts} />
)}
{linkType === PublishChannelEnum.wecom && (
<Wecom appId={appId} onRefreshOutLinkCounts={refetchOutLinkCounts} />
)}
{linkType === PublishChannelEnum.officialAccount && (
<OffiAccount appId={appId} onRefreshOutLinkCounts={refetchOutLinkCounts} />
)}
{linkType === PublishChannelEnum.wechat && (
<Wechat appId={appId} onRefreshOutLinkCounts={refetchOutLinkCounts} />
)}
{linkType === PublishChannelEnum.playground && <Playground appId={appId} />}
</Box>
</Box>
</Flex>
);
};
......
......@@ -5,7 +5,7 @@ import { useContextSelector } from 'use-context-selector';
import { AppContext, TabEnum } from '../context';
import { useMount } from 'ahooks';
import Header from './Header';
import { Flex } from '@chakra-ui/react';
import { Box, Flex } from '@chakra-ui/react';
import { workflowBoxStyles } from '../constants';
import dynamic from 'next/dynamic';
import { cloneDeep } from 'lodash-es';
......@@ -44,15 +44,17 @@ const WorkflowEdit = () => {
flexDirection={'column'}
flex={1}
minH={0}
mt={'72px'}
px={4}
pb={4}
mt={['8px', '72px']}
bg={'white'}
overflowY={'auto'}
overflowX={'hidden'}
>
{currentTab === TabEnum.publish && <PublishChannel />}
{currentTab === TabEnum.logs && <Logs />}
{currentTab === TabEnum.logs && (
<Box px={4} pb={4} h={'full'}>
<Logs />
</Box>
)}
</Flex>
)}
</Flex>
......
import { Types } from '@fastgpt/service/common/mongo';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { authApp } from '@fastgpt/service/support/permission/app/auth';
import { ManagePermissionVal } from '@fastgpt/global/support/permission/constant';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import type { ApiRequestProps } from '@fastgpt/next/type';
import { NextAPI } from '@/service/middleware/entry';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import {
OutLinkCountQuerySchema,
OutLinkCountResponseSchema,
type OutLinkCountQueryType,
type OutLinkCountResponseType
} from '@fastgpt/global/openapi/support/outLink/api';
const countTypes = [
PublishChannelEnum.share,
PublishChannelEnum.feishu,
PublishChannelEnum.dingtalk,
PublishChannelEnum.wecom,
PublishChannelEnum.wechat,
PublishChannelEnum.officialAccount
] as const;
type OutLinkCountType = (typeof countTypes)[number];
/** 获取侧栏展示的发布渠道连接数,缺失渠道补零。 */
async function handler(
req: ApiRequestProps<undefined, OutLinkCountQueryType>
): Promise<OutLinkCountResponseType> {
const { appId } = parseApiInput({
req,
querySchema: OutLinkCountQuerySchema
}).query;
const { teamId } = await authApp({
req,
authToken: true,
appId,
per: ManagePermissionVal
});
const groupedCounts = await MongoOutLink.aggregate<{ _id: OutLinkCountType; count: number }>([
{
$match: {
teamId: new Types.ObjectId(teamId),
appId: new Types.ObjectId(appId),
type: { $in: countTypes }
}
},
{
$group: {
_id: '$type',
count: { $sum: 1 }
}
}
]);
const counts: Record<OutLinkCountType, number> = {
[PublishChannelEnum.share]: 0,
[PublishChannelEnum.feishu]: 0,
[PublishChannelEnum.dingtalk]: 0,
[PublishChannelEnum.wecom]: 0,
[PublishChannelEnum.wechat]: 0,
[PublishChannelEnum.officialAccount]: 0
};
groupedCounts.forEach(({ _id, count }) => {
counts[_id] = count;
});
return OutLinkCountResponseSchema.parse(counts);
}
export default NextAPI(handler);
......@@ -11,6 +11,8 @@ import type {
import type {
OutLinkCreateBodyType,
OutLinkCreateResponseType,
OutLinkCountQueryType,
OutLinkCountResponseType,
OutLinkDeleteQueryType,
OutLinkDeleteResponseType,
OutLinkListQueryType,
......@@ -37,6 +39,9 @@ export function getShareChatList(data: OutLinkListQueryType) {
return GET<OutLinkListResponseType>(`/support/outLink/list`, data);
}
export const getOutLinkCounts = (data: OutLinkCountQueryType) =>
GET<OutLinkCountResponseType>('/support/outLink/count', data);
// delete a shareChat
export function delShareChatById(id: OutLinkDeleteQueryType['id']) {
return DELETE<OutLinkDeleteResponseType>(`/support/outLink/delete?id=${id}`);
......
import type { OutLinkCountResponseType } from '@fastgpt/global/openapi/support/outLink/api';
import { PublishChannelEnum } from '@fastgpt/global/support/outLink/constant';
import { MongoApp } from '@fastgpt/service/core/app/schema';
import { MongoOutLink } from '@fastgpt/service/support/outLink/schema';
import { getRootUser } from '@test/datas/users';
import { Call } from '@test/utils/request';
import { beforeEach, describe, expect, it } from 'vitest';
import * as countApi from '@/pages/api/support/outLink/count';
describe('OutLink Count API', () => {
let rootUser: Awaited<ReturnType<typeof getRootUser>>;
let testApp: Awaited<ReturnType<typeof MongoApp.create>>;
let otherApp: Awaited<ReturnType<typeof MongoApp.create>>;
let emptyApp: Awaited<ReturnType<typeof MongoApp.create>>;
beforeEach(async () => {
rootUser = await getRootUser();
const appData = {
type: 'simple' as const,
tmbId: rootUser.tmbId,
teamId: rootUser.teamId
};
[testApp, otherApp, emptyApp] = await Promise.all([
MongoApp.create({ ...appData, name: 'Test App for OutLink Count' }),
MongoApp.create({ ...appData, name: 'Other App for OutLink Count' }),
MongoApp.create({ ...appData, name: 'Empty App for OutLink Count' })
]);
});
const createOutLink = (appId: string, type: PublishChannelEnum) =>
MongoOutLink.create({
shareId: `${appId}-${type}-${Math.random()}`,
teamId: rootUser.teamId,
tmbId: rootUser.tmbId,
appId,
name: `Test ${type}`,
type
});
it('aggregates counted channels, fills missing values, excludes other types and isolates apps', async () => {
await Promise.all([
createOutLink(testApp._id, PublishChannelEnum.share),
createOutLink(testApp._id, PublishChannelEnum.share),
createOutLink(testApp._id, PublishChannelEnum.feishu),
createOutLink(testApp._id, PublishChannelEnum.wecom),
createOutLink(testApp._id, PublishChannelEnum.officialAccount),
createOutLink(testApp._id, PublishChannelEnum.apikey),
createOutLink(testApp._id, PublishChannelEnum.playground),
createOutLink(testApp._id, PublishChannelEnum.iframe),
createOutLink(otherApp._id, PublishChannelEnum.share),
createOutLink(otherApp._id, PublishChannelEnum.dingtalk),
createOutLink(otherApp._id, PublishChannelEnum.wechat)
]);
const res = await Call<OutLinkCountResponseType>(countApi.default, {
auth: rootUser,
query: { appId: testApp._id }
});
expect(res.code).toBe(200);
expect(res.data).toEqual({
share: 2,
feishu: 1,
dingtalk: 0,
wecom: 1,
wechat: 0,
official_account: 1
});
});
it('returns zero for every countable channel without configurations', async () => {
const res = await Call<OutLinkCountResponseType>(countApi.default, {
auth: rootUser,
query: { appId: emptyApp._id }
});
expect(res.code).toBe(200);
expect(res.data).toEqual({
share: 0,
feishu: 0,
dingtalk: 0,
wecom: 0,
wechat: 0,
official_account: 0
});
});
it('rejects a missing appId', async () => {
const res = await Call<OutLinkCountResponseType>(countApi.default, {
auth: rootUser,
query: {}
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
it('rejects unauthenticated requests', async () => {
const res = await Call<OutLinkCountResponseType>(countApi.default, {
query: { appId: testApp._id }
});
expect(res.code).toBe(500);
expect(res.error).toBeDefined();
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment