feat(ui): new WorkflowList page

This commit is contained in:
Fu Diwei 2025-07-20 22:07:27 +08:00
parent 004d8dbd0a
commit b34ddf3e56
16 changed files with 288 additions and 289 deletions

View File

@ -38,11 +38,9 @@ const Empty = (props: EmptyProps) => {
</Show>
<div className="my-2">
<Show when={!!title}>
{isPrimitive(title) ? (
<Typography.Title level={4}>{title}</Typography.Title>
) : (
<h4 style={{ color: themeToken.colorTextHeading }}>{title}</h4>
)}
<div className="pb-2 text-xl" style={{ color: themeToken.colorTextLabel }}>
{title}
</div>
</Show>
<Show when={!!description}>
{isPrimitive(description) ? (

View File

@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Button, Drawer, Space, notification } from "antd";
import { App, Button, Drawer, Space } from "antd";
import { type AccessModel } from "@/domain/access";
import { useTriggerElement, useZustandShallowSelector } from "@/hooks";
@ -24,7 +24,7 @@ export type AccessEditDrawerProps = {
const AccessEditDrawer = ({ data, loading, trigger, scene, usage, afterSubmit, ...props }: AccessEditDrawerProps) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { notification } = App.useApp();
const { createAccess, updateAccess } = useAccessesStore(useZustandShallowSelector(["createAccess", "updateAccess"]));
@ -70,7 +70,7 @@ const AccessEditDrawer = ({ data, loading, trigger, scene, usage, afterSubmit, .
afterSubmit?.(values);
setOpen(false);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
} finally {
@ -86,8 +86,6 @@ const AccessEditDrawer = ({ data, loading, trigger, scene, usage, afterSubmit, .
return (
<>
{NotificationContextHolder}
{triggerEl}
<Drawer

View File

@ -1,7 +1,7 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useControllableValue } from "ahooks";
import { Modal, notification } from "antd";
import { App, Modal } from "antd";
import { type AccessModel } from "@/domain/access";
import { useTriggerElement, useZustandShallowSelector } from "@/hooks";
@ -24,7 +24,7 @@ export type AccessEditModalProps = {
const AccessEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ...props }: AccessEditModalProps) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { notification } = App.useApp();
const { createAccess, updateAccess } = useAccessesStore(useZustandShallowSelector(["createAccess", "updateAccess"]));
@ -70,7 +70,7 @@ const AccessEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ..
afterSubmit?.(values);
setOpen(false);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
} finally {
@ -86,8 +86,6 @@ const AccessEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ..
return (
<>
{NotificationContextHolder}
{triggerEl}
<Modal
@ -112,7 +110,7 @@ const AccessEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ..
onOk={handleOkClick}
onCancel={handleCancelClick}
>
<div className="pb-2 pt-4">
<div className="pt-4 pb-2">
<AccessForm ref={formRef} initialValues={data} scene={scene === "create" ? "create" : "edit"} usage={usage} />
</div>
</Modal>

View File

@ -2,7 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconBrowserShare, IconCheck, IconChevronRight, IconDownload, IconSettings2 } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { Button, Collapse, Divider, Dropdown, Empty, Flex, Skeleton, Spin, Table, type TableProps, Tooltip, Typography, notification, theme } from "antd";
import { App, Button, Collapse, Divider, Dropdown, Empty, Flex, Skeleton, Spin, Table, type TableProps, Tooltip, Typography, theme } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
@ -276,7 +276,7 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin
const WorkflowRunArtifacts = ({ runId }: { runId: string }) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { notification } = App.useApp();
const tableColumns: TableProps<CertificateModel>["columns"] = [
{
@ -337,7 +337,7 @@ const WorkflowRunArtifacts = ({ runId }: { runId: string }) => {
}
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
@ -346,8 +346,6 @@ const WorkflowRunArtifacts = ({ runId }: { runId: string }) => {
return (
<>
{NotificationContextHolder}
<Typography.Title level={5}>{t("workflow_run.artifacts")}</Typography.Title>
<Table<CertificateModel>
columns={tableColumns}

View File

@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { IconBrowserShare, IconPlayerPause, IconTrash } from "@tabler/icons-react";
import { IconBrowserShare, IconHistory, IconPlayerPause, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { Alert, Button, Empty, Modal, Table, type TableProps, Tooltip, notification } from "antd";
import { Alert, App, Button, Skeleton, Table, type TableProps, Tooltip } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import { cancelRun as cancelWorkflowRun } from "@/api/workflows";
import Empty from "@/components/Empty";
import WorkflowStatusTag from "@/components/workflow/WorkflowStatusTag";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
@ -28,8 +29,7 @@ export type WorkflowRunsProps = {
const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const { t } = useTranslation();
const [modalApi, ModelContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { modal, notification } = App.useApp();
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
@ -108,27 +108,29 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
return (
<div className="flex items-center justify-end">
<WorkflowRunDetailDrawer
data={record}
trigger={
<Tooltip title={t("workflow_run.action.view")}>
<Button color="primary" icon={<IconBrowserShare size="1.25em" />} variant="text" />
</Tooltip>
}
/>
<Tooltip title={t("workflow_run.action.view")}>
<Button
color="primary"
icon={<IconBrowserShare size="1.25em" />}
variant="text"
onClick={(e) => {
e.stopPropagation();
handleRecordDetailClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow_run.action.cancel")}>
<Button
color="default"
disabled={!allowCancel}
icon={<IconPlayerPause size="1.25em" />}
variant="text"
onClick={() => {
handleCancelClick(record);
onClick={(e) => {
e.stopPropagation();
handleRecordCancelClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow_run.action.delete")}>
<Button
color="danger"
@ -136,8 +138,9 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
disabled={!aloowDelete}
icon={<IconTrash size="1.25em" />}
variant="text"
onClick={() => {
handleDeleteClick(record);
onClick={(e) => {
e.stopPropagation();
handleRecordDeleteClick(record);
}}
/>
</Tooltip>
@ -173,7 +176,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
}
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
@ -205,8 +208,16 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
};
}, [tableData]);
const handleCancelClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
const [detailRecord, setDetailRecord] = useState<WorkflowRunModel>();
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const handleRecordDetailClick = (workflowRun: WorkflowRunModel) => {
setDetailRecord(workflowRun);
setDetailOpen(true);
};
const handleRecordCancelClick = (workflowRun: WorkflowRunModel) => {
modal.confirm({
title: t("workflow_run.action.cancel"),
content: t("workflow_run.action.cancel.confirm"),
onOk: async () => {
@ -217,14 +228,14 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDeleteClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
const handleRecordDeleteClick = (workflowRun: WorkflowRunModel) => {
modal.confirm({
title: <span className="text-error">{t("workflow_run.action.delete")}</span>,
content: (
<span
@ -247,46 +258,49 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
return (
<>
{ModelContextHolder}
{NotificationContextHolder}
<div className={className} style={style}>
<Alert className="mb-4" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.table.alert") }}></span>} showIcon type="info" />
<div className={className} style={style}>
<Alert className="mb-4" type="warning" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_run.table.alert") }}></span>} />
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: loading ? <Skeleton /> : <Empty title={t("common.text.nodata")} icon={<IconHistory size={24} />} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
onRow={(record) => ({
onClick: () => {
handleRecordDetailClick(record);
},
})}
/>
<Table<WorkflowRunModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : undefined} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</div>
</>
<WorkflowRunDetailDrawer data={detailRecord} open={detailOpen} onOpenChange={setDetailOpen} />
</div>
);
};

View File

@ -2,7 +2,7 @@ import { memo, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { IconCopy, IconDotsVertical, IconLabel, IconTrashX } from "@tabler/icons-react";
import { useControllableValue } from "ahooks";
import { Button, Card, Drawer, Dropdown, Input, type InputRef, type MenuProps, Modal, Popover, Space } from "antd";
import { App, Button, Card, Drawer, Dropdown, Input, type InputRef, type MenuProps, Popover, Space } from "antd";
import { produce } from "immer";
import { isEqual } from "radash";
@ -95,12 +95,12 @@ const isNodeUnremovable = (node: WorkflowNode) => {
const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex, afterUpdate, afterDelete }: SharedNodeMenuProps) => {
const { t } = useTranslation();
const { modal } = App.useApp();
const { duplicateNode, updateNode, removeNode, duplicateBranch, removeBranch } = useWorkflowStore(
useZustandShallowSelector(["duplicateNode", "updateNode", "removeNode", "duplicateBranch", "removeBranch"])
);
const [modalApi, ModelContextHolder] = Modal.useModal();
const nameInputRef = useRef<InputRef>(null);
const nameRef = useRef<string>();
@ -152,7 +152,7 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex,
onClick: () => {
nameRef.current = node.name;
const dialog = modalApi.confirm({
const dialog = modal.confirm({
title: isNodeBranchLike(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"),
content: (
<div className="pt-4 pb-2">
@ -221,18 +221,14 @@ const SharedNodeMenu = ({ menus, trigger, node, disabled, branchId, branchIndex,
}, [disabled, node]);
return (
<>
{ModelContextHolder}
<Dropdown
menu={{
items: menuItems,
}}
trigger={["click"]}
>
{trigger}
</Dropdown>
</>
<Dropdown
menu={{
items: menuItems,
}}
trigger={["click"]}
>
{trigger}
</Dropdown>
);
};
// #endregion
@ -305,7 +301,7 @@ const SharedNodeConfigDrawer = ({
}: SharedNodeEditDrawerProps) => {
const { t } = useTranslation();
const [modalApi, ModelContextHolder] = Modal.useModal();
const { modal } = App.useApp();
const [open, setOpen] = useControllableValue<boolean>(props, {
valuePropName: "open",
@ -333,7 +329,7 @@ const SharedNodeConfigDrawer = ({
const { promise, resolve, reject } = Promise.withResolvers();
if (changed) {
modalApi.confirm({
modal.confirm({
title: t("common.text.operation_confirm"),
content: t("workflow_node.unsaved_changes.confirm"),
onOk: () => resolve(void 0),
@ -347,44 +343,40 @@ const SharedNodeConfigDrawer = ({
};
return (
<>
{ModelContextHolder}
<Drawer
afterOpenChange={setOpen}
closable={!pending}
destroyOnHidden
extra={
<SharedNodeMenu
menus={["rename", "remove"]}
node={node}
disabled={disabled}
trigger={<Button icon={<IconDotsVertical size="1.25em" />} type="text" />}
afterDelete={() => {
setOpen(false);
}}
/>
}
footer={
!!footer && (
<Space className="w-full justify-end">
<Button onClick={handleCancelClick}>{t("common.button.cancel")}</Button>
<Button disabled={disabled} loading={pending} type="primary" onClick={handleConfirmClick}>
{t("common.button.save")}
</Button>
</Space>
)
}
loading={loading}
maskClosable={!pending}
open={open}
title={<div className="max-w-[480px] truncate">{node.name}</div>}
width={720}
onClose={handleClose}
>
{children}
</Drawer>
</>
<Drawer
afterOpenChange={setOpen}
closable={!pending}
destroyOnHidden
extra={
<SharedNodeMenu
menus={["rename", "remove"]}
node={node}
disabled={disabled}
trigger={<Button icon={<IconDotsVertical size="1.25em" />} type="text" />}
afterDelete={() => {
setOpen(false);
}}
/>
}
footer={
!!footer && (
<Space className="w-full justify-end">
<Button onClick={handleCancelClick}>{t("common.button.cancel")}</Button>
<Button disabled={disabled} loading={pending} type="primary" onClick={handleConfirmClick}>
{t("common.button.save")}
</Button>
</Space>
)
}
loading={loading}
maskClosable={!pending}
open={open}
title={<div className="max-w-[480px] truncate">{node.name}</div>}
width={720}
onClose={handleClose}
>
{children}
</Drawer>
);
};
// #endregion

View File

@ -28,7 +28,7 @@
"settings.sslprovider.tab": "Certificate authority",
"settings.sslprovider.ca.title": "System-wide CA",
"settings.sslprovider.ca.tips": "The certificate validity lifetime, certificate algorithm, domain names count, and support for wildcard domain names are allowed may vary among different providers. After switching service providers, please check whether the configuration of the workflows needs to be adjusted.",
"settings.sslprovider.ca.tips": "You can set different CAs for each workflow as well.<br>The certificate validity lifetime, certificate algorithm, domain names count, and support for wildcard domain names are allowed may vary among different providers. After switching service providers, please check whether the configuration of the workflows needs to be adjusted.",
"settings.sslprovider.form.provider.label": "ACME CA provider",
"settings.sslprovider.form.letsencryptstaging_alert": "The staging environment can reduce the chance of your running up against rate limits.<br><br>Learn more:<br><a href=\"https://letsencrypt.org/docs/staging-environment/\" target=\"_blank\">https://letsencrypt.org/docs/staging-environment/</a>",
"settings.sslprovider.form.zerossl_eab_kid.label": "EAB KID",

View File

@ -2,7 +2,9 @@
"workflow.page.title": "Workflows",
"workflow.page.subtitle": "Workflows are collections of nodes that automate a process. Workflows begin execution when a trigger condition occurs and execute sequentially to achieve complex tasks.",
"workflow.nodata": "No workflows. Please create a workflow to generate certificates! 😀",
"workflow.nodata.title": "No workflows",
"workflow.nodata.description": "It looks like you don't have any workflows. Get started by adding one.",
"workflow.nodata.button": "Create workflow",
"workflow.search.placeholder": "Search by workflow name ...",
@ -22,7 +24,8 @@
"workflow.props.trigger.auto": "Scheduled",
"workflow.props.trigger.manual": "Manual",
"workflow.props.last_run_at": "Last run at",
"workflow.props.state": "State",
"workflow.props.state": "Active",
"workflow.props.state.filter.all": "All",
"workflow.props.state.filter.enabled": "Active",
"workflow.props.state.filter.disabled": "Inactive",
"workflow.props.created_at": "Created at",

View File

@ -5,7 +5,7 @@
"workflow_run.action.delete": "Delete run",
"workflow_run.action.delete.confirm": "Are you sure want to delete this \"{{name}}\" workflow run?<br>This action cannot be undone.",
"workflow_run.table.alert": "Attention: The workflow run contains the execution results of each node. Deleting it may trigger re-application or re-deployment of certificates due to the inability to find the previous execution result. Please do not delete unless necessary. It is recommended to keep it for at least 180 days.",
"workflow_run.table.alert": "The workflow run contains the execution results of each node. Deleting it may trigger re-application or re-deployment of certificates due to the inability to find the previous execution result. Please do not delete unless necessary. It is recommended to keep it for at least 180 days.",
"workflow_run.props.id": "ID",
"workflow_run.props.status": "Status",

View File

@ -28,7 +28,7 @@
"settings.sslprovider.tab": "证书颁发机构",
"settings.sslprovider.ca.title": "全局证书颁发机构",
"settings.sslprovider.ca.tips": "不同服务商所支持的证书有效期、证书算法、多域名数量上限、是否允许泛域名等可能不同,切换服务商后请注意检查已有工作流的配置是否需要调整。",
"settings.sslprovider.ca.tips": "你也可以在每个工作流中设置不同的证书颁发机构。<br>不同服务商所支持的证书有效期、证书算法、多域名数量上限、是否允许泛域名等可能不同,切换服务商后请注意检查已有工作流的配置是否需要调整。",
"settings.sslprovider.form.provider.label": "证书颁发机构提供商",
"settings.sslprovider.form.letsencryptstaging_alert": "测试环境比生产环境有更宽松的速率限制,可进行测试性部署。<br><br>点击下方链接了解更多:<br><a href=\"https://letsencrypt.org/zh-cn/docs/staging-environment/\" target=\"_blank\">https://letsencrypt.org/zh-cn/docs/staging-environment/</a>",
"settings.sslprovider.form.zerossl_eab_kid.label": "EAB KID",

View File

@ -1,8 +1,10 @@
{
"workflow.page.title": "工作流",
"workflow.page.subtitle": "工作流是自动化流程的节点集合。当满足触发条件时,工作流开始顺序执行各节点以完成复杂的任务(如证书申请、部署等)。",
"workflow.page.subtitle": "工作流是自动化流程的节点集合。当满足触发条件时,工作流开始顺序执行各节点以完成复杂的任务。",
"workflow.nodata": "暂无工作流,请先新建工作流",
"workflow.nodata.title": "暂无工作流",
"workflow.nodata.description": "请先新建工作流以执行证书申请、部署等任务",
"workflow.nodata.button": "新建工作流",
"workflow.search.placeholder": "按工作流名称搜索……",
@ -22,7 +24,8 @@
"workflow.props.trigger.auto": "定时",
"workflow.props.trigger.manual": "手动",
"workflow.props.last_run_at": "最近执行时间",
"workflow.props.state": "启用状态",
"workflow.props.state": "启用",
"workflow.props.state.filter.all": "全部",
"workflow.props.state.filter.enabled": "启用",
"workflow.props.state.filter.disabled": "未启用",
"workflow.props.created_at": "创建时间",

View File

@ -52,7 +52,7 @@ const AccessList = () => {
render: (_, record) => {
return (
<div className="flex max-w-full items-center gap-4 truncate overflow-hidden">
<Avatar shape="square" src={accessProvidersMap.get(record.provider)?.icon} size="large" />
<Avatar shape="square" src={accessProvidersMap.get(record.provider)?.icon} size={28} />
<div className="flex max-w-full flex-col gap-1">
<Typography.Text ellipsis>{record.name}</Typography.Text>
<Typography.Text ellipsis type="secondary">

View File

@ -428,7 +428,9 @@ const SettingsSSLProvider = () => {
<Show when={!loading} fallback={<Skeleton active />}>
<Form form={formInst} disabled={formPending} layout="vertical" initialValues={{ provider: providerType }}>
<div className="mb-2">
<Typography.Text type="secondary">{t("settings.sslprovider.ca.tips")}</Typography.Text>
<Typography.Text type="secondary">
<span dangerouslySetInnerHTML={{ __html: t("settings.sslprovider.ca.tips") }}></span>
</Typography.Text>
</div>
<Form.Item name="provider" label={t("settings.sslprovider.form.provider.label")}>

View File

@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { PageHeader } from "@ant-design/pro-components";
import { IconArrowBackUp, IconChevronDown, IconDots, IconHistory, IconPlayerPlay, IconRobot, IconTrash } from "@tabler/icons-react";
import { Alert, Button, Card, Dropdown, Form, Input, Modal, Space, Tabs, Typography, message, notification } from "antd";
import { Alert, App, Button, Card, Dropdown, Flex, Form, Input, Space, Tabs } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { isEqual } from "radash";
import { z } from "zod/v4";
@ -25,9 +24,7 @@ const WorkflowDetail = () => {
const { t } = useTranslation();
const [messageApi, MessageContextHolder] = message.useMessage();
const [modalApi, ModalContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { message, modal, notification } = App.useApp();
const { id: workflowId } = useParams();
const { workflow, initialized, ...workflowState } = useWorkflowStore(
@ -79,7 +76,7 @@ const WorkflowDetail = () => {
const handleEnableChange = async () => {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
messageApi.warning(t("workflow.action.enable.failed.uncompleted"));
message.warning(t("workflow.action.enable.failed.uncompleted"));
return;
}
@ -87,12 +84,12 @@ const WorkflowDetail = () => {
await workflowState.setEnabled(!workflow.enabled);
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
};
const handleDeleteClick = () => {
modalApi.confirm({
modal.confirm({
title: <span className="text-error">{t("workflow.action.delete")}</span>,
content: <span dangerouslySetInnerHTML={{ __html: t("workflow.action.delete.confirm", { name: workflow.name }) }} />,
icon: (
@ -110,24 +107,24 @@ const WorkflowDetail = () => {
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDiscardClick = () => {
modalApi.confirm({
modal.confirm({
title: t("workflow.detail.orchestration.action.discard"),
content: t("workflow.detail.orchestration.action.discard.confirm"),
onOk: async () => {
try {
await workflowState.discard();
messageApi.success(t("common.text.operation_succeeded"));
message.success(t("common.text.operation_succeeded"));
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
@ -135,21 +132,21 @@ const WorkflowDetail = () => {
const handleReleaseClick = () => {
if (!isAllNodesValidated(workflow.draft!)) {
messageApi.warning(t("workflow.detail.orchestration.action.release.failed.uncompleted"));
message.warning(t("workflow.detail.orchestration.action.release.failed.uncompleted"));
return;
}
modalApi.confirm({
modal.confirm({
title: t("workflow.detail.orchestration.action.release"),
content: t("workflow.detail.orchestration.action.release.confirm"),
onOk: async () => {
try {
await workflowState.release();
messageApi.success(t("common.text.operation_succeeded"));
message.success(t("common.text.operation_succeeded"));
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
@ -158,7 +155,7 @@ const WorkflowDetail = () => {
const handleRunClick = () => {
const { promise, resolve, reject } = Promise.withResolvers();
if (workflow.hasDraft) {
modalApi.confirm({
modal.confirm({
title: t("workflow.detail.orchestration.action.run"),
content: t("workflow.detail.orchestration.action.run.confirm"),
onOk: () => resolve(void 0),
@ -184,63 +181,62 @@ const WorkflowDetail = () => {
await startWorkflowRun(workflowId!);
messageApi.info(t("workflow.detail.orchestration.action.run.prompt"));
message.info(t("workflow.detail.orchestration.action.run.prompt"));
} catch (err) {
setIsPendingOrRunning(false);
unsubscribeFn?.();
console.error(err);
messageApi.warning(t("common.text.operation_failed"));
message.warning(t("common.text.operation_failed"));
}
});
};
return (
<div className="flex size-full flex-col">
{MessageContextHolder}
{ModalContextHolder}
{NotificationContextHolder}
<Card styles={{ body: { padding: 0 } }}>
<div className="px-6 py-4">
<div className="mx-auto max-w-320">
<div className="flex justify-between gap-2">
<div>
<h1>{workflow.name || " "}</h1>
<p className="mb-0 text-base text-gray-500">{workflow.description || " "}</p>
</div>
<Flex gap="small">
{initialized
? [
<WorkflowBaseInfoModal key="edit" trigger={<Button>{t("common.button.edit")}</Button>} />,
<div>
<Card styles={{ body: { padding: "0.5rem", paddingBottom: 0 } }} style={{ borderRadius: 0 }}>
<PageHeader
style={{ paddingBottom: 0 }}
title={workflow.name}
extra={
initialized
? [
<WorkflowBaseInfoModal key="edit" trigger={<Button>{t("common.button.edit")}</Button>} />,
<Button key="enable" onClick={handleEnableChange}>
{workflow.enabled ? t("workflow.action.disable") : t("workflow.action.enable")}
</Button>,
<Button key="enable" onClick={handleEnableChange}>
{workflow.enabled ? t("workflow.action.disable") : t("workflow.action.enable")}
</Button>,
<Dropdown
key="more"
menu={{
items: [
{
key: "delete",
label: t("workflow.action.delete"),
danger: true,
icon: <IconTrash size="1.25em" />,
onClick: () => {
handleDeleteClick();
<Dropdown
key="more"
menu={{
items: [
{
key: "delete",
label: t("workflow.action.delete"),
danger: true,
icon: <IconTrash size="1.25em" />,
onClick: () => {
handleDeleteClick();
},
},
},
],
}}
trigger={["click"]}
>
<Button icon={<IconChevronDown size="1.25em" />} iconPosition="end">
{t("common.button.more")}
</Button>
</Dropdown>,
]
: []
}
>
<Typography.Paragraph type="secondary">{workflow.description}</Typography.Paragraph>
],
}}
trigger={["click"]}
>
<Button icon={<IconChevronDown size="1.25em" />} iconPosition="end">
{t("common.button.more")}
</Button>
</Dropdown>,
]
: []}
</Flex>
</div>
<Tabs
activeKey={tabValue}
defaultActiveKey="orchestration"
@ -268,9 +264,9 @@ const WorkflowDetail = () => {
tabBarStyle={{ border: "none" }}
onChange={(key) => setTabValue(key as typeof tabValue)}
/>
</PageHeader>
</Card>
</div>
</div>
</div>
</Card>
<Show when={tabValue === "orchestration"}>
<div className="min-h-[360px] flex-1 overflow-hidden p-4">
@ -285,7 +281,7 @@ const WorkflowDetail = () => {
}}
loading={!initialized}
>
<div className="absolute inset-x-6 top-4 z-2 flex items-center justify-between gap-4">
<div className="absolute inset-x-6 top-4 z-2 mx-auto flex max-w-320 items-center justify-between gap-4">
<div className="flex-1 overflow-hidden">
<Show when={workflow.hasDraft!}>
<Alert banner message={<div className="truncate">{t("workflow.detail.orchestration.draft.alert")}</div>} type="warning" />
@ -330,9 +326,9 @@ const WorkflowDetail = () => {
<Show when={tabValue === "runs"}>
<div className="p-4">
<Card loading={!initialized}>
<div className="mx-auto max-w-320">
<WorkflowRuns workflowId={workflowId!} />
</Card>
</div>
</div>
</Show>
</div>
@ -342,7 +338,7 @@ const WorkflowDetail = () => {
const WorkflowBaseInfoModal = ({ trigger }: { trigger?: React.ReactNode }) => {
const { t } = useTranslation();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const { notification } = App.useApp();
const { workflow, ...workflowState } = useWorkflowStore(useZustandShallowSelector(["workflow", "setBaseInfo"]));
@ -368,7 +364,7 @@ const WorkflowBaseInfoModal = ({ trigger }: { trigger?: React.ReactNode }) => {
try {
await workflowState.setBaseInfo(values.name!, values.description!);
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
notification.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
}
@ -381,8 +377,6 @@ const WorkflowBaseInfoModal = ({ trigger }: { trigger?: React.ReactNode }) => {
return (
<>
{NotificationContextHolder}
<ModalForm
disabled={formPending}
layout="vertical"

View File

@ -1,12 +1,13 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { IconCopy, IconEdit, IconPlus, IconReload, IconTrash } from "@tabler/icons-react";
import { IconCirclePlus, IconCopy, IconEdit, IconPlus, IconReload, IconSchema, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Button, Divider, Empty, Input, Menu, type MenuProps, Radio, Space, Switch, Table, type TableProps, Tooltip, Typography, theme } from "antd";
import { App, Button, Flex, Input, Segmented, Skeleton, Switch, Table, type TableProps, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import Empty from "@/components/Empty";
import WorkflowStatusIcon from "@/components/workflow/WorkflowStatusIcon";
import { WORKFLOW_TRIGGERS, type WorkflowModel, cloneNode, initWorkflow, isAllNodesValidated } from "@/domain/workflow";
import { list as listWorkflows, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
@ -19,7 +20,6 @@ const WorkflowList = () => {
const { t } = useTranslation();
const { message, modal, notification } = App.useApp();
const { token: themeToken } = theme.useToken();
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
@ -43,12 +43,12 @@ const WorkflowList = () => {
title: t("workflow.props.name"),
ellipsis: true,
render: (_, record) => (
<Space className="max-w-full" direction="vertical" size={4}>
<div className="flex max-w-full flex-col gap-1">
<Typography.Text ellipsis>{record.name}</Typography.Text>
<Typography.Text type="secondary" ellipsis>
{record.description}
<Typography.Text ellipsis type="secondary">
{record.description || " "}
</Typography.Text>
</Space>
</div>
),
},
{
@ -63,10 +63,10 @@ const WorkflowList = () => {
return <Typography.Text>{t("workflow.props.trigger.manual")}</Typography.Text>;
} else if (trigger === WORKFLOW_TRIGGERS.AUTO) {
return (
<Space className="max-w-full" direction="vertical" size={4}>
<div className="flex max-w-full flex-col gap-1">
<Typography.Text>{t("workflow.props.trigger.auto")}</Typography.Text>
<Typography.Text type="secondary">{record.triggerCron ?? ""}</Typography.Text>
</Space>
</div>
);
}
},
@ -75,62 +75,21 @@ const WorkflowList = () => {
key: "state",
title: t("workflow.props.state"),
defaultFilteredValue: searchParams.has("state") ? [searchParams.get("state") as string] : undefined,
filterDropdown: ({ setSelectedKeys, confirm, clearFilters }) => {
const items: Required<MenuProps>["items"] = [
["enabled", "workflow.props.state.filter.enabled"],
["disabled", "workflow.props.state.filter.disabled"],
].map(([key, label]) => {
return {
key,
label: <Radio checked={filters["state"] === key}>{t(label)}</Radio>,
onClick: () => {
if (filters["state"] !== key) {
setPage(1);
setFilters((prev) => ({ ...prev, state: key }));
setSelectedKeys([key]);
}
confirm({ closeDropdown: true });
},
};
});
const handleResetClick = () => {
setPage(1);
setFilters((prev) => ({ ...prev, state: undefined }));
setSelectedKeys([]);
clearFilters?.();
confirm();
};
const handleConfirmClick = () => {
confirm();
};
return (
<div style={{ padding: 0 }}>
<Menu items={items} selectable={false} />
<Divider className="my-0" />
<Space className="w-full justify-end" style={{ padding: themeToken.paddingSM }}>
<Button size="small" disabled={!filters.state} onClick={handleResetClick}>
{t("common.button.reset")}
</Button>
<Button type="primary" size="small" onClick={handleConfirmClick}>
{t("common.button.ok")}
</Button>
</Space>
</div>
);
},
render: (_, record) => {
const enabled = record.enabled;
return (
<Switch
checked={enabled}
onChange={() => {
handleRecordActiveChange(record);
<div
onClick={(e) => {
e.stopPropagation();
}}
/>
>
<Switch
checked={enabled}
onChange={() => {
handleRecordActiveChange(record);
}}
/>
</div>
);
},
},
@ -139,10 +98,10 @@ const WorkflowList = () => {
title: t("workflow.props.last_run_at"),
render: (_, record) => {
return (
<Space>
<Flex gap="small">
<WorkflowStatusIcon color={true} status={record.lastRunStatus!} />
<Typography.Text>{record.lastRunTime ? dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss") : ""}</Typography.Text>
</Space>
</Flex>
);
},
},
@ -154,14 +113,6 @@ const WorkflowList = () => {
return dayjs(record.created!).format("YYYY-MM-DD HH:mm:ss");
},
},
{
key: "updatedAt",
title: t("workflow.props.updated_at"),
ellipsis: true,
render: (_, record) => {
return dayjs(record.updated!).format("YYYY-MM-DD HH:mm:ss");
},
},
{
key: "$action",
align: "end",
@ -174,8 +125,9 @@ const WorkflowList = () => {
color="primary"
icon={<IconEdit size="1.25em" />}
variant="text"
onClick={() => {
navigate(`/workflows/${record.id}`);
onClick={(e) => {
e.stopPropagation();
handleRecordDetailClick(record);
}}
/>
</Tooltip>
@ -186,6 +138,7 @@ const WorkflowList = () => {
icon={<IconCopy size="1.25em" />}
variant="text"
onClick={() => {
e.stopPropagation();
handleRecordDuplicateClick(record);
}}
/>
@ -197,7 +150,8 @@ const WorkflowList = () => {
danger
icon={<IconTrash size="1.25em" />}
variant="text"
onClick={() => {
onClick={(e) => {
e.stopPropagation();
handleRecordDeleteClick(record);
}}
/>
@ -256,6 +210,10 @@ const WorkflowList = () => {
refreshData();
};
const handleRecordDetailClick = (workflow: WorkflowModel) => {
navigate(`/workflows/${workflow.id}`);
};
const handleRecordActiveChange = async (workflow: WorkflowModel) => {
try {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
@ -347,6 +305,22 @@ const WorkflowList = () => {
<div className="flex items-center justify-between gap-x-2 gap-y-3 not-md:flex-col-reverse not-md:items-start not-md:justify-normal">
<div className="flex w-full flex-1 items-center gap-x-2 md:max-w-200">
<div>
<Segmented
className="shadow-xs"
options={[
{ label: <span className="text-sm">{t("workflow.props.state.filter.all")}</span>, value: "" },
{ label: <span className="text-sm">{t("workflow.props.state.filter.enabled")}</span>, value: "enabled" },
{ label: <span className="text-sm">{t("workflow.props.state.filter.disabled")}</span>, value: "disabled" },
]}
size="large"
value={(filters["state"] as string) || ""}
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, state: value }));
}}
/>
</div>
<div className="flex-1">
<Input.Search
className="text-sm placeholder:text-sm"
@ -374,7 +348,26 @@ const WorkflowList = () => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("workflow.nodata"))} />,
emptyText: loading ? (
<Skeleton />
) : (
<Empty
title={t("workflow.nodata.title")}
description={getErrMsg(loadedError ?? t("workflow.nodata.description"))}
icon={<IconSchema size={24} />}
extra={
loadedError ? (
<Button icon={<IconReload size="1.25em" />} type="primary" onClick={handleReloadClick}>
{t("common.button.reload")}
</Button>
) : (
<Button icon={<IconCirclePlus size="1.25em" />} type="primary" onClick={handleCreateClick}>
{t("workflow.nodata.button")}
</Button>
)
}
/>
),
}}
pagination={{
current: page,
@ -390,8 +383,14 @@ const WorkflowList = () => {
setPageSize(pageSize);
},
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
onRow={(record) => ({
onClick: () => {
handleRecordDetailClick(record);
},
})}
/>
</div>
</div>

View File

@ -123,7 +123,7 @@ const WorkflowNew = () => {
</Card>
<div className="p-4">
<div className="mx-auto max-w-320 px-2">
<div className="mx-auto max-w-320">
<Typography.Text type="secondary">
<div className="mt-4 mb-8 text-xl">{t("workflow.new.templates.title")}</div>
</Typography.Text>