feat: plugin deprecation notice (#22685)

Co-authored-by: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Co-authored-by: twwu <twwu@dify.ai>
This commit is contained in:
Junyan Qin (Chin)
2025-07-22 10:27:35 +08:00
committed by GitHub
parent eb06de0921
commit 2d8eace34b
11 changed files with 241 additions and 33 deletions

View File

@@ -32,6 +32,13 @@ class MarketplacePluginDeclaration(BaseModel):
latest_package_identifier: str = Field( latest_package_identifier: str = Field(
..., description="Unique identifier for the latest package release of the plugin" ..., description="Unique identifier for the latest package release of the plugin"
) )
status: str = Field(..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`")
deprecated_reason: str = Field(
..., description="Not empty when status='deleted', indicates the reason why this plugin is deleted(deprecated)"
)
alternative_plugin_id: str = Field(
..., description="Optional, indicates the alternative plugin for user to switch to"
)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod

View File

@@ -38,6 +38,9 @@ class PluginService:
plugin_id: str plugin_id: str
version: str version: str
unique_identifier: str unique_identifier: str
status: str
deprecated_reason: str
alternative_plugin_id: str
REDIS_KEY_PREFIX = "plugin_service:latest_plugin:" REDIS_KEY_PREFIX = "plugin_service:latest_plugin:"
REDIS_TTL = 60 * 5 # 5 minutes REDIS_TTL = 60 * 5 # 5 minutes
@@ -71,6 +74,9 @@ class PluginService:
plugin_id=plugin_id, plugin_id=plugin_id,
version=manifest.latest_version, version=manifest.latest_version,
unique_identifier=manifest.latest_package_identifier, unique_identifier=manifest.latest_package_identifier,
status=manifest.status,
deprecated_reason=manifest.deprecated_reason,
alternative_plugin_id=manifest.alternative_plugin_id,
) )
# Store in Redis # Store in Redis

View File

@@ -0,0 +1,104 @@
import React, { useMemo } from 'react'
import type { FC } from 'react'
import Link from 'next/link'
import cn from '@/utils/classnames'
import { RiAlertFill } from '@remixicon/react'
import { Trans } from 'react-i18next'
import { snakeCase2CamelCase } from '@/utils/format'
import { useMixedTranslation } from '../marketplace/hooks'
type DeprecationNoticeProps = {
status: 'deleted' | 'active'
deprecatedReason: string
alternativePluginId: string
alternativePluginURL: string
locale?: string
className?: string
innerWrapperClassName?: string
iconWrapperClassName?: string
textClassName?: string
}
const i18nPrefix = 'plugin.detailPanel.deprecation'
const DeprecationNotice: FC<DeprecationNoticeProps> = ({
status,
deprecatedReason,
alternativePluginId,
alternativePluginURL,
locale,
className,
innerWrapperClassName,
iconWrapperClassName,
textClassName,
}) => {
const { t } = useMixedTranslation(locale)
const deprecatedReasonKey = useMemo(() => {
if (!deprecatedReason) return ''
return snakeCase2CamelCase(deprecatedReason)
}, [deprecatedReason])
// Check if the deprecatedReasonKey exists in i18n
const hasValidDeprecatedReason = useMemo(() => {
if (!deprecatedReason || !deprecatedReasonKey) return false
// Define valid reason keys that exist in i18n
const validReasonKeys = ['businessAdjustments', 'ownershipTransferred', 'noMaintainer']
return validReasonKeys.includes(deprecatedReasonKey)
}, [deprecatedReason, deprecatedReasonKey])
if (status !== 'deleted')
return null
return (
<div className={cn('w-full', className)}>
<div className={cn(
'relative flex items-start gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
innerWrapperClassName,
)}>
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-toast-warning-bg opacity-40' />
<div className={cn('flex size-6 shrink-0 items-center justify-center', iconWrapperClassName)}>
<RiAlertFill className='size-4 text-text-warning-secondary' />
</div>
<div className={cn('system-xs-regular grow py-1 text-text-primary', textClassName)}>
{
hasValidDeprecatedReason && alternativePluginId && (
<Trans
i18nKey={`${i18nPrefix}.fullMessage`}
components={{
CustomLink: (
<Link
href={alternativePluginURL}
target='_blank'
rel='noopener noreferrer'
className='underline'
/>
),
}}
values={{
deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`),
alternativePluginId,
}}
/>
)
}
{
hasValidDeprecatedReason && !alternativePluginId && (
<span>
{t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })}
</span>
)
}
{
!hasValidDeprecatedReason && (
<span>{t(`${i18nPrefix}.noReason`)}</span>
)
}
</div>
</div>
</div>
)
}
export default React.memo(DeprecationNotice)

View File

@@ -29,7 +29,7 @@ import Toast from '@/app/components/base/toast'
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
import { Github } from '@/app/components/base/icons/src/public/common' import { Github } from '@/app/components/base/icons/src/public/common'
import { uninstallPlugin } from '@/service/plugins' import { uninstallPlugin } from '@/service/plugins'
import { useGetLanguage } from '@/context/i18n' import { useGetLanguage, useI18N } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context' import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { useInvalidateAllToolProviders } from '@/service/use-tools' import { useInvalidateAllToolProviders } from '@/service/use-tools'
@@ -39,6 +39,7 @@ import { getMarketplaceUrl } from '@/utils/var'
import { PluginAuth } from '@/app/components/plugins/plugin-auth' import { PluginAuth } from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth' import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { useAllToolProviders } from '@/service/use-tools' import { useAllToolProviders } from '@/service/use-tools'
import DeprecationNotice from '../base/deprecation-notice'
const i18nPrefix = 'plugin.action' const i18nPrefix = 'plugin.action'
@@ -56,6 +57,7 @@ const DetailHeader = ({
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const locale = useGetLanguage() const locale = useGetLanguage()
const { locale: currentLocale } = useI18N()
const { checkForUpdates, fetchReleases } = useGitHubReleases() const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext() const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext() const { refreshModelProviders } = useProviderContext()
@@ -70,6 +72,9 @@ const DetailHeader = ({
latest_version, latest_version,
meta, meta,
plugin_id, plugin_id,
status,
deprecated_reason,
alternative_plugin_id,
} = detail } = detail
const { author, category, name, label, description, icon, verified, tool } = detail.declaration const { author, category, name, label, description, icon, verified, tool } = detail.declaration
const isTool = category === PluginType.tool const isTool = category === PluginType.tool
@@ -98,7 +103,7 @@ const DetailHeader = ({
if (isFromGitHub) if (isFromGitHub)
return `https://github.com/${meta!.repo}` return `https://github.com/${meta!.repo}`
if (isFromMarketplace) if (isFromMarketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { theme }) return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
return '' return ''
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme]) }, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
@@ -272,6 +277,15 @@ const DetailHeader = ({
</ActionButton> </ActionButton>
</div> </div>
</div> </div>
{isFromMarketplace && (
<DeprecationNotice
status={status}
deprecatedReason={deprecated_reason}
alternativePluginId={alternative_plugin_id}
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
className='mt-3'
/>
)}
<Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description> <Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>
{ {
category === PluginType.tool && ( category === PluginType.tool && (

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { import {
RiArrowRightUpLine, RiArrowRightUpLine,
@@ -55,6 +55,8 @@ const PluginItem: FC<Props> = ({
endpoints_active, endpoints_active,
meta, meta,
plugin_id, plugin_id,
status,
deprecated_reason,
} = plugin } = plugin
const { category, author, name, label, description, icon, verified, meta: declarationMeta } = plugin.declaration const { category, author, name, label, description, icon, verified, meta: declarationMeta } = plugin.declaration
@@ -70,9 +72,14 @@ const PluginItem: FC<Props> = ({
return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0')
}, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version]) }, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version])
const handleDelete = () => { const isDeprecated = useMemo(() => {
return status === 'deleted' && !!deprecated_reason
}, [status, deprecated_reason])
const handleDelete = useCallback(() => {
refreshPluginList({ category } as any) refreshPluginList({ category } as any)
} }, [category, refreshPluginList])
const getValueFromI18nObject = useRenderI18nObject() const getValueFromI18nObject = useRenderI18nObject()
const title = getValueFromI18nObject(label) const title = getValueFromI18nObject(label)
const descriptionText = getValueFromI18nObject(description) const descriptionText = getValueFromI18nObject(description)
@@ -81,7 +88,7 @@ const PluginItem: FC<Props> = ({
return ( return (
<div <div
className={cn( className={cn(
'rounded-xl border-[1.5px] border-background-section-burn p-1', 'relative overflow-hidden rounded-xl border-[1.5px] border-background-section-burn p-1',
currentPluginID === plugin_id && 'border-components-option-card-option-selected-border', currentPluginID === plugin_id && 'border-components-option-card-option-selected-border',
source === PluginSource.debugging source === PluginSource.debugging
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]' ? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
@@ -91,10 +98,10 @@ const PluginItem: FC<Props> = ({
setCurrentPluginID(plugin.plugin_id) setCurrentPluginID(plugin.plugin_id)
}} }}
> >
<div className={cn('hover-bg-components-panel-on-panel-item-bg relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}> <div className={cn('hover-bg-components-panel-on-panel-item-bg relative z-10 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}>
<CornerMark text={categoriesMap[category].label} /> <CornerMark text={categoriesMap[category].label} />
{/* Header */} {/* Header */}
<div className="flex"> <div className='flex'>
<div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'> <div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'>
<img <img
className='h-full w-full' className='h-full w-full'
@@ -102,13 +109,13 @@ const PluginItem: FC<Props> = ({
alt={`plugin-${plugin_unique_identifier}-logo`} alt={`plugin-${plugin_unique_identifier}-logo`}
/> />
</div> </div>
<div className="ml-3 w-0 grow"> <div className='ml-3 w-0 grow'>
<div className="flex h-5 items-center"> <div className='flex h-5 items-center'>
<Title title={title} /> <Title title={title} />
{verified && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />} {verified && <RiVerifiedBadgeLine className='ml-0.5 h-4 w-4 shrink-0 text-text-accent' />}
{!isDifyVersionCompatible && <Tooltip popupContent={ {!isDifyVersionCompatible && <Tooltip popupContent={
t('plugin.difyVersionNotCompatible', { minimalDifyVersion: declarationMeta.minimum_dify_version }) t('plugin.difyVersionNotCompatible', { minimalDifyVersion: declarationMeta.minimum_dify_version })
}><RiErrorWarningLine color='red' className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" /></Tooltip>} }><RiErrorWarningLine color='red' className='ml-0.5 h-4 w-4 shrink-0 text-text-accent' /></Tooltip>}
<Badge className='ml-1 shrink-0' <Badge className='ml-1 shrink-0'
text={source === PluginSource.github ? plugin.meta!.version : plugin.version} text={source === PluginSource.github ? plugin.meta!.version : plugin.version}
hasRedCornerMark={(source === PluginSource.marketplace) && !!plugin.latest_version && plugin.latest_version !== plugin.version} hasRedCornerMark={(source === PluginSource.marketplace) && !!plugin.latest_version && plugin.latest_version !== plugin.version}
@@ -135,10 +142,11 @@ const PluginItem: FC<Props> = ({
</div> </div>
</div> </div>
</div> </div>
<div className='mb-1 mt-1.5 flex h-4 items-center justify-between px-4'> <div className='mb-1 mt-1.5 flex h-4 items-center gap-x-2 px-4'>
<div className='flex items-center'> {/* Organization & Name */}
<div className='flex grow items-center overflow-hidden'>
<OrgInfo <OrgInfo
className="mt-0.5" className='mt-0.5'
orgName={orgName} orgName={orgName}
packageName={name} packageName={name}
packageNameClassName='w-auto max-w-[150px]' packageNameClassName='w-auto max-w-[150px]'
@@ -146,15 +154,20 @@ const PluginItem: FC<Props> = ({
{category === PluginType.extension && ( {category === PluginType.extension && (
<> <>
<div className='system-xs-regular mx-2 text-text-quaternary'>·</div> <div className='system-xs-regular mx-2 text-text-quaternary'>·</div>
<div className='system-xs-regular flex space-x-1 text-text-tertiary'> <div className='system-xs-regular flex space-x-1 overflow-hidden text-text-tertiary'>
<RiLoginCircleLine className='h-4 w-4' /> <RiLoginCircleLine className='h-4 w-4 shrink-0' />
<span>{t('plugin.endpointsEnabled', { num: endpoints_active })}</span> <span
className='truncate'
title={t('plugin.endpointsEnabled', { num: endpoints_active })}
>
{t('plugin.endpointsEnabled', { num: endpoints_active })}
</span>
</div> </div>
</> </>
)} )}
</div> </div>
{/* Source */}
<div className='flex items-center'> <div className='flex shrink-0 items-center'>
{source === PluginSource.github {source === PluginSource.github
&& <> && <>
<a href={`https://github.com/${meta!.repo}`} target='_blank' className='flex items-center gap-1'> <a href={`https://github.com/${meta!.repo}`} target='_blank' className='flex items-center gap-1'>
@@ -192,7 +205,20 @@ const PluginItem: FC<Props> = ({
</> </>
} }
</div> </div>
{/* Deprecated */}
{source === PluginSource.marketplace && enable_marketplace && isDeprecated && (
<div className='system-2xs-medium-uppercase flex shrink-0 items-center gap-x-2'>
<span className='text-text-tertiary'>·</span>
<span className='text-text-warning'>
{t('plugin.deprecated')}
</span>
</div>
)}
</div> </div>
{/* BG Effect for Deprecated Plugin */}
{source === PluginSource.marketplace && enable_marketplace && isDeprecated && (
<div className='absolute bottom-[-71px] right-[-45px] z-0 size-40 bg-components-badge-status-light-warning-halo opacity-60 blur-[120px]' />
)}
</div> </div>
) )
} }

View File

@@ -36,6 +36,9 @@ const PluginsPanel = () => {
...plugin, ...plugin,
latest_version: installedLatestVersion?.versions[plugin.plugin_id]?.version ?? '', latest_version: installedLatestVersion?.versions[plugin.plugin_id]?.version ?? '',
latest_unique_identifier: installedLatestVersion?.versions[plugin.plugin_id]?.unique_identifier ?? '', latest_unique_identifier: installedLatestVersion?.versions[plugin.plugin_id]?.unique_identifier ?? '',
status: installedLatestVersion?.versions[plugin.plugin_id]?.status ?? 'active',
deprecated_reason: installedLatestVersion?.versions[plugin.plugin_id]?.deprecated_reason ?? '',
alternative_plugin_id: installedLatestVersion?.versions[plugin.plugin_id]?.alternative_plugin_id ?? '',
})) || [] })) || []
}, [pluginList, installedLatestVersion]) }, [pluginList, installedLatestVersion])
@@ -66,20 +69,25 @@ const PluginsPanel = () => {
onFilterChange={handleFilterChange} onFilterChange={handleFilterChange}
/> />
</div> </div>
{isPluginListLoading ? <Loading type='app' /> : (filteredList?.length ?? 0) > 0 ? ( {isPluginListLoading && <Loading type='app' />}
<div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'> {!isPluginListLoading && (
<div className='w-full'> <>
<List pluginList={filteredList || []} /> {(filteredList?.length ?? 0) > 0 ? (
</div> <div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'>
{!isLastPage && !isFetching && ( <div className='w-full'>
<Button onClick={loadNextPage}> <List pluginList={filteredList || []} />
{t('workflow.common.loadMore')} </div>
</Button> {!isLastPage && !isFetching && (
<Button onClick={loadNextPage}>
{t('workflow.common.loadMore')}
</Button>
)}
{isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>}
</div>
) : (
<Empty />
)} )}
{isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>} </>
</div>
) : (
<Empty />
)} )}
<PluginDetailPanel <PluginDetailPanel
detail={currentPluginDetail} detail={currentPluginDetail}

View File

@@ -118,6 +118,9 @@ export type PluginDetail = {
latest_unique_identifier: string latest_unique_identifier: string
source: PluginSource source: PluginSource
meta?: MetaData meta?: MetaData
status: 'active' | 'deleted'
deprecated_reason: string
alternative_plugin_id: string
} }
export type PluginInfoFromMarketPlace = { export type PluginInfoFromMarketPlace = {
@@ -343,6 +346,9 @@ export type InstalledLatestVersionResponse = {
[plugin_id: string]: { [plugin_id: string]: {
unique_identifier: string unique_identifier: string
version: string version: string
status: 'active' | 'deleted'
deprecated_reason: string
alternative_plugin_id: string
} | null } | null
} }
} }

View File

@@ -29,6 +29,7 @@ const translation = {
searchTools: 'Search tools...', searchTools: 'Search tools...',
installPlugin: 'Install plugin', installPlugin: 'Install plugin',
installFrom: 'INSTALL FROM', installFrom: 'INSTALL FROM',
deprecated: 'Deprecated',
list: { list: {
noInstalled: 'No plugins installed', noInstalled: 'No plugins installed',
notFound: 'No plugins found', notFound: 'No plugins found',
@@ -99,6 +100,16 @@ const translation = {
configureApp: 'Configure App', configureApp: 'Configure App',
configureModel: 'Configure model', configureModel: 'Configure model',
configureTool: 'Configure tool', configureTool: 'Configure tool',
deprecation: {
fullMessage: 'This plugin has been deprecated due to {{deprecatedReason}}, and will no longer be updated. Please use <CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink> instead.',
onlyReason: 'This plugin has been deprecated due to {{deprecatedReason}} and will no longer be updated.',
noReason: 'This plugin has been deprecated and will no longer be updated.',
reason: {
businessAdjustments: 'business adjustments',
ownershipTransferred: 'ownership transferred',
noMaintainer: 'no maintainer',
},
},
}, },
install: '{{num}} installs', install: '{{num}} installs',
installAction: 'Install', installAction: 'Install',

View File

@@ -84,6 +84,16 @@ const translation = {
actionNum: '{{num}} {{action}} が含まれています', actionNum: '{{num}} {{action}} が含まれています',
endpointsDocLink: 'ドキュメントを表示する', endpointsDocLink: 'ドキュメントを表示する',
switchVersion: 'バージョンの切り替え', switchVersion: 'バージョンの切り替え',
deprecation: {
fullMessage: 'このプラグインは{{deprecatedReason}}のため非推奨となり、新しいバージョンはリリースされません。代わりに<CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink>をご利用ください。',
onlyReason: 'このプラグインは{{deprecatedReason}}のため非推奨となり、新しいバージョンはリリースされません。',
noReason: 'このプラグインは廃止されており、今後更新されることはありません。',
reason: {
businessAdjustments: '事業調整',
ownershipTransferred: '所有権移転',
noMaintainer: 'メンテナーの不足',
},
},
}, },
debugInfo: { debugInfo: {
title: 'デバッグ', title: 'デバッグ',
@@ -198,6 +208,7 @@ const translation = {
install: '{{num}} インストール', install: '{{num}} インストール',
installAction: 'インストール', installAction: 'インストール',
installFrom: 'インストール元', installFrom: 'インストール元',
deprecated: '非推奨',
searchPlugins: '検索プラグイン', searchPlugins: '検索プラグイン',
search: '検索', search: '検索',
endpointsEnabled: '{{num}} セットのエンドポイントが有効になりました', endpointsEnabled: '{{num}} セットのエンドポイントが有効になりました',

View File

@@ -29,6 +29,7 @@ const translation = {
searchTools: '搜索工具...', searchTools: '搜索工具...',
installPlugin: '安装插件', installPlugin: '安装插件',
installFrom: '安装源', installFrom: '安装源',
deprecated: '已弃用',
list: { list: {
noInstalled: '无已安装的插件', noInstalled: '无已安装的插件',
notFound: '未找到插件', notFound: '未找到插件',
@@ -99,6 +100,16 @@ const translation = {
configureApp: '应用设置', configureApp: '应用设置',
configureModel: '模型设置', configureModel: '模型设置',
configureTool: '工具设置', configureTool: '工具设置',
deprecation: {
fullMessage: '由于{{deprecatedReason}},此插件已被弃用,将不再发布新版本。请使用<CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink>替代。',
onlyReason: '由于{{deprecatedReason}},此插件已被弃用,将不再发布新版本。',
noReason: '此插件已被弃用,将不再发布新版本。',
reason: {
businessAdjustments: '业务调整',
ownershipTransferred: '所有权转移',
noMaintainer: '无人维护',
},
},
}, },
install: '{{num}} 次安装', install: '{{num}} 次安装',
installAction: '安装', installAction: '安装',

View File

@@ -56,3 +56,7 @@ export const downloadFile = ({ data, fileName }: { data: Blob; fileName: string
a.remove() a.remove()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
} }
export const snakeCase2CamelCase = (input: string): string => {
return input.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}