feat: version tag (#14949)

This commit is contained in:
Wu Tianwei
2025-03-07 18:10:40 +08:00
committed by GitHub
parent 4aaf07d62a
commit 3ca1373274
41 changed files with 1695 additions and 423 deletions

View File

@@ -4,10 +4,10 @@ import {
useCallback,
useMemo,
} from 'react'
import { RiApps2AddLine } from '@remixicon/react'
import { RiApps2AddLine, RiHistoryLine } from '@remixicon/react'
import { useNodes } from 'reactflow'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useContext, useContextSelector } from 'use-context-selector'
import {
useStore,
useWorkflowStore,
@@ -15,6 +15,7 @@ import {
import {
BlockEnum,
InputVarType,
WorkflowVersion,
} from '../types'
import type { StartNodeType } from '../nodes/start/types'
import {
@@ -27,7 +28,7 @@ import {
useWorkflowRun,
} from '../hooks'
import AppPublisher from '../../app/app-publisher'
import { ToastContext } from '../../base/toast'
import Toast, { ToastContext } from '../../base/toast'
import Divider from '../../base/divider'
import RunAndHistory from './run-and-history'
import EditingTitle from './editing-title'
@@ -36,18 +37,22 @@ import RestoringTitle from './restoring-title'
import ViewHistory from './view-history'
import ChatVariableButton from './chat-variable-button'
import EnvButton from './env-button'
import VersionHistoryModal from './version-history-modal'
import VersionHistoryButton from './version-history-button'
import Button from '@/app/components/base/button'
import { useStore as useAppStore } from '@/app/components/app/store'
import { publishWorkflow } from '@/service/workflow'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import { useFeatures } from '@/app/components/base/features/hooks'
import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow'
import type { PublishWorkflowParams } from '@/types/workflow'
import { fetchAppDetail, fetchAppSSO } from '@/service/apps'
import AppContext from '@/context/app-context'
const Header: FC = () => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore(s => s.appDetail)
const appSidebarExpand = useAppStore(s => s.appSidebarExpand)
const setAppDetail = useAppStore(s => s.setAppDetail)
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
const appID = appDetail?.id
const isChatMode = useIsChatMode()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
@@ -55,6 +60,10 @@ const Header: FC = () => {
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
const currentVersion = useStore(s => s.currentVersion)
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const selectedNode = nodes.find(node => node.data.selected)
@@ -104,27 +113,70 @@ const Header: FC = () => {
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
workflowStore.setState({ isRestoring: false })
}, [workflowStore, handleLoadBackupDraft])
setShowWorkflowVersionHistoryPanel(false)
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory(appDetail!.id)
const handleRestore = useCallback(() => {
setShowWorkflowVersionHistoryPanel(false)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true)
}, [handleSyncWorkflowDraft, workflowStore])
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('workflow.versionHistory.action.restoreSuccess'),
})
},
onError: () => {
Toast.notify({
type: 'error',
message: t('workflow.versionHistory.action.restoreFailure'),
})
},
onSettled: () => {
resetWorkflowVersionHistory()
},
})
}, [handleSyncWorkflowDraft, workflowStore, setShowWorkflowVersionHistoryPanel, resetWorkflowVersionHistory, t])
const onPublish = useCallback(async () => {
const updateAppDetail = useCallback(async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appID! })
if (systemFeatures.enable_web_sso_switch_component) {
const ssoRes = await fetchAppSSO({ appId: appID! })
setAppDetail({ ...res, enable_sso: ssoRes.enabled })
}
else {
setAppDetail({ ...res })
}
}
catch (error) {
console.error(error)
}
}, [appID, setAppDetail, systemFeatures.enable_web_sso_switch_component])
const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!)
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
if (handleCheckBeforePublish()) {
const res = await publishWorkflow(`/apps/${appID}/workflows/publish`)
const res = await publishWorkflow({
title: params?.title || '',
releaseNotes: params?.releaseNotes || '',
})
if (res) {
notify({ type: 'success', message: t('common.api.actionSuccess') })
updateAppDetail()
workflowStore.getState().setPublishedAt(res.created_at)
resetWorkflowVersionHistory()
}
}
else {
throw new Error('Checklist failed')
}
}, [appID, handleCheckBeforePublish, notify, t, workflowStore])
}, [handleCheckBeforePublish, notify, t, workflowStore, publishWorkflow, resetWorkflowVersionHistory, updateAppDetail])
const onStartRestoring = useCallback(() => {
workflowStore.setState({ isRestoring: true })
@@ -132,7 +184,11 @@ const Header: FC = () => {
// clear right panel
if (selectedNode)
handleNodeSelect(selectedNode.id, true)
}, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode])
setShowWorkflowVersionHistoryPanel(true)
setShowEnvPanel(false)
setShowDebugAndPreviewPanel(false)
}, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode,
setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel])
const onPublisherToggle = useCallback((state: boolean) => {
if (state)
@@ -153,11 +209,6 @@ const Header: FC = () => {
className='absolute top-0 left-0 z-10 flex items-center justify-between w-full px-3 h-14 bg-mask-top2bottom-gray-50-to-transparent'
>
<div>
{
appSidebarExpand === 'collapse' && (
<div className='system-xs-regular text-text-tertiary'>{appDetail?.name}</div>
)
}
{
normal && <EditingTitle />
}
@@ -189,11 +240,11 @@ const Header: FC = () => {
inputs: variables,
onRefreshData: handleToolConfigureUpdate,
onPublish,
onRestore: onStartRestoring,
onToggle: onPublisherToggle,
crossAxisOffset: 4,
}}
/>
<VersionHistoryButton onClick={onStartRestoring} />
</div>
)
}
@@ -214,27 +265,23 @@ const Header: FC = () => {
}
{
restoring && (
<div className='flex flex-col mt-auto'>
<div className='flex items-center justify-end my-4'>
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
<div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div>
<Button
className='mr-2'
onClick={handleCancelRestore}
>
{t('common.operation.cancel')}
</Button>
<Button
onClick={handleRestore}
variant='primary'
>
{t('workflow.common.restore')}
</Button>
</div>
<VersionHistoryModal />
<div className='flex justify-end items-center gap-x-2'>
<Button
onClick={handleRestore}
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
variant='primary'
>
{t('workflow.common.restore')}
</Button>
<Button
className='text-components-button-secondary-accent-text'
onClick={handleCancelRestore}
>
<div className='flex items-center gap-x-0.5'>
<RiHistoryLine className='w-4 h-4' />
<span className='px-0.5'>{t('workflow.common.exitVersions')}</span>
</div>
</Button>
</div>
)
}

View File

@@ -1,19 +1,47 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useWorkflow } from '../hooks'
import { useStore } from '../store'
import { ClockRefresh } from '@/app/components/base/icons/src/vender/line/time'
import { WorkflowVersion } from '../types'
import useTimestamp from '@/hooks/use-timestamp'
const RestoringTitle = () => {
const { t } = useTranslation()
const { formatTimeFromNow } = useWorkflow()
const publishedAt = useStore(state => state.publishedAt)
const { formatTime } = useTimestamp()
const currentVersion = useStore(state => state.currentVersion)
const isDraft = currentVersion?.version === WorkflowVersion.Draft
const publishStatus = isDraft ? t('workflow.common.unpublished') : t('workflow.common.published')
const versionName = useMemo(() => {
if (isDraft)
return t('workflow.versionHistory.currentDraft')
return currentVersion?.marked_name || t('workflow.versionHistory.defaultName')
}, [currentVersion, t, isDraft])
return (
<div className='flex items-center h-[18px] text-xs text-gray-500'>
<ClockRefresh className='mr-1 w-3 h-3 text-gray-500' />
{t('workflow.common.latestPublished')}<span> </span>
{formatTimeFromNow(publishedAt)}
<div className='flex flex-col gap-y-0.5'>
<div className='flex items-center gap-x-1'>
<span className='text-text-primary system-sm-semibold'>
{versionName}
</span>
<span className='px-1 py-0.5 rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm text-text-accent-secondary system-2xs-medium-uppercase'>
{t('workflow.common.viewOnly')}
</span>
</div>
<div className='flex items-center gap-x-1 h-4 text-text-tertiary system-xs-regular'>
{
currentVersion && (
<>
<span>{publishStatus}</span>
<span>·</span>
<span>{`${formatTimeFromNow((isDraft ? currentVersion.updated_at : currentVersion.created_at) * 1000)} ${formatTime(currentVersion.created_at, 'HH:mm:ss')}`}</span>
<span>·</span>
<span>{currentVersion?.created_by?.name || ''}</span>
</>
)
}
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import React, { type FC, useCallback } from 'react'
import { RiHistoryLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useKeyPress } from 'ahooks'
import Button from '../../base/button'
import Tooltip from '../../base/tooltip'
import { getKeyboardKeyCodeBySystem } from '../utils'
type VersionHistoryButtonProps = {
onClick: () => Promise<unknown> | unknown
}
const VERSION_HISTORY_SHORTCUT = ['⌘', '⇧', 'H']
const PopupContent = React.memo(() => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1'>
<div className='text-text-secondary system-xs-medium px-0.5'>
{t('workflow.common.versionHistory')}
</div>
<div className='flex items-center gap-x-0.5'>
{VERSION_HISTORY_SHORTCUT.map(key => (
<span
key={key}
className='rounded-[4px] bg-components-kbd-bg-white text-text-tertiary system-kbd px-[1px]'
>
{key}
</span>
))}
</div>
</div>
)
})
PopupContent.displayName = 'PopupContent'
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
onClick,
}) => {
const handleViewVersionHistory = useCallback(async () => {
await onClick?.()
}, [onClick])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => {
e.preventDefault()
handleViewVersionHistory()
}
, { exactMatch: true, useCapture: true })
return <Tooltip
popupContent={<PopupContent />}
noDecoration
popupClassName='rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg
shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px] p-1.5'
>
<Button
className={'p-2'}
onClick={handleViewVersionHistory}
>
<RiHistoryLine className='w-4 h-4 text-components-button-secondary-text' />
</Button>
</Tooltip>
}
export default VersionHistoryButton

View File

@@ -1,66 +0,0 @@
import React from 'react'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { WorkflowVersion } from '../types'
import cn from '@/utils/classnames'
import type { VersionHistory } from '@/types/workflow'
type VersionHistoryItemProps = {
item: VersionHistory
selectedVersion: string
onClick: (item: VersionHistory) => void
curIdx: number
page: number
}
const formatVersion = (version: string, curIdx: number, page: number): string => {
if (curIdx === 0 && page === 1)
return WorkflowVersion.Draft
if (curIdx === 1 && page === 1)
return WorkflowVersion.Latest
try {
const date = new Date(version)
if (isNaN(date.getTime()))
return version
// format as YYYY-MM-DD HH:mm:ss
return date.toISOString().slice(0, 19).replace('T', ' ')
}
catch {
return version
}
}
const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ item, selectedVersion, onClick, curIdx, page }) => {
const { t } = useTranslation()
const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss')
const formattedVersion = formatVersion(item.version, curIdx, page)
const renderVersionLabel = (version: string) => (
(version === WorkflowVersion.Draft || version === WorkflowVersion.Latest)
? (
<div className="shrink-0 px-1 border bg-white border-[rgba(0,0,0,0.08)] rounded-[5px] truncate">
{version}
</div>
)
: null
)
return (
<div
className={cn(
'flex items-center p-2 h-12 text-xs font-medium text-gray-700 justify-between',
formattedVersion === selectedVersion ? '' : 'hover:bg-gray-100',
formattedVersion === WorkflowVersion.Draft ? 'cursor-not-allowed' : 'cursor-pointer',
)}
onClick={() => item.version !== WorkflowVersion.Draft && onClick(item)}
>
<div className='flex flex-col gap-1 py-2'>
<span className="text-left">{formatTime(formattedVersion === WorkflowVersion.Draft ? item.updated_at : item.created_at)}</span>
<span className="text-left">{t('workflow.panel.createdBy')} {item.created_by.name}</span>
</div>
{renderVersionLabel(formattedVersion)}
</div>
)
}
export default React.memo(VersionHistoryItem)

View File

@@ -1,89 +0,0 @@
'use client'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useWorkflowRun } from '../hooks'
import VersionHistoryItem from './version-history-item'
import type { VersionHistory } from '@/types/workflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchPublishedAllWorkflow } from '@/service/workflow'
import Loading from '@/app/components/base/loading'
import Button from '@/app/components/base/button'
const limit = 10
const VersionHistoryModal = () => {
const [selectedVersion, setSelectedVersion] = useState('draft')
const [page, setPage] = useState(1)
const { handleRestoreFromPublishedWorkflow } = useWorkflowRun()
const appDetail = useAppStore.getState().appDetail
const { t } = useTranslation()
const {
data: versionHistory,
isLoading,
} = useSWR(
`/apps/${appDetail?.id}/workflows?page=${page}&limit=${limit}`,
fetchPublishedAllWorkflow,
)
const handleVersionClick = (item: VersionHistory) => {
if (item.version !== selectedVersion) {
setSelectedVersion(item.version)
handleRestoreFromPublishedWorkflow(item)
}
}
const handleNextPage = () => {
if (versionHistory?.has_more)
setPage(page => page + 1)
}
return (
<div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl p-2'>
<div className="max-h-[400px] overflow-auto">
{(isLoading && page) === 1
? (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
)
: (
<>
{versionHistory?.items?.map((item, idx) => (
<VersionHistoryItem
key={item.version}
item={item}
selectedVersion={selectedVersion}
onClick={handleVersionClick}
curIdx={idx}
page={page}
/>
))}
{isLoading && page > 1 && (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
)}
{!isLoading && versionHistory?.has_more && (
<div className='flex items-center justify-center h-10 mt-2'>
<Button
className='text-sm'
onClick={handleNextPage}
>
{t('workflow.common.loadMore')}
</Button>
</div>
)}
{!isLoading && !versionHistory?.items?.length && (
<div className='flex items-center justify-center h-10 text-gray-500'>
{t('workflow.common.noHistory')}
</div>
)}
</>
)}
</div>
</div>
)
}
export default React.memo(VersionHistoryModal)