feat: deprecate old notification module and introduce new notifier module

This commit is contained in:
Fu Diwei
2025-04-24 20:27:20 +08:00
parent 2d17501072
commit 7478dd7f47
27 changed files with 692 additions and 265 deletions

View File

@@ -0,0 +1,67 @@
import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Space, Typography } from "antd";
import { type NotifyProvider, notifyProvidersMap } from "@/domain/provider";
export type NotifyProviderSelectProps = Omit<
SelectProps,
"filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"
> & {
filter?: (record: NotifyProvider) => boolean;
};
const NotifyProviderSelect = ({ filter, ...props }: NotifyProviderSelectProps) => {
const { t } = useTranslation();
const [options, setOptions] = useState<Array<{ key: string; value: string; label: string; data: NotifyProvider }>>([]);
useEffect(() => {
const allItems = Array.from(notifyProvidersMap.values());
const filteredItems = filter != null ? allItems.filter(filter) : allItems;
setOptions(
filteredItems.map((item) => ({
key: item.type,
value: item.type,
label: t(item.name),
data: item,
}))
);
}, [filter]);
const renderOption = (key: string) => {
const provider = notifyProvidersMap.get(key);
return (
<Space className="max-w-full grow overflow-hidden truncate" size={4}>
<Avatar src={provider?.icon} size="small" />
<Typography.Text className="leading-loose" ellipsis>
{t(provider?.name ?? "")}
</Typography.Text>
</Space>
);
};
return (
<Select
{...props}
filterOption={(inputValue, option) => {
if (!option) return false;
const value = inputValue.toLowerCase();
return option.value.toLowerCase().includes(value) || option.label.toLowerCase().includes(value);
}}
labelRender={({ label, value }) => {
if (!label) {
return <Typography.Text type="secondary">{props.placeholder}</Typography.Text>;
}
return renderOption(value as string);
}}
options={options}
optionFilterProp={undefined}
optionLabelProp={undefined}
optionRender={(option) => renderOption(option.data.value)}
/>
);
};
export default memo(NotifyProviderSelect);

View File

@@ -193,6 +193,7 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin
const NEWLINE = "\n";
const logstr = listData
.map((group) => {
const escape = (str: string) => str.replaceAll("\r", "\\r").replaceAll("\n", "\\n");
return (
group.name +
NEWLINE +
@@ -200,8 +201,9 @@ const WorkflowRunLogs = ({ runId, runStatus }: { runId: string; runStatus: strin
.map((record) => {
const datetime = dayjs(record.timestamp).format("YYYY-MM-DDTHH:mm:ss.SSSZ");
const level = record.level;
const message = record.message.trim().replaceAll("\r", "\\r").replaceAll("\n", "\\n");
return `[${datetime}] [${level}] ${message}`;
const message = record.message;
const data = record.data && Object.keys(record.data).length > 0 ? JSON.stringify(record.data) : "";
return `[${datetime}] [${level}] ${escape(message)} ${escape(data)}`.trim();
})
.join(NEWLINE)
);

View File

@@ -1,8 +1,9 @@
import { memo, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Flex, Typography } from "antd";
import { Avatar, Flex, Typography } from "antd";
import { produce } from "immer";
import { notifyProvidersMap } from "@/domain/provider";
import { notifyChannelsMap } from "@/domain/settings";
import { type WorkflowNodeConfigForNotify, WorkflowNodeType } from "@/domain/workflow";
import { useZustandShallowSelector } from "@/hooks";
@@ -39,9 +40,11 @@ const NotifyNode = ({ node, disabled }: NotifyNodeProps) => {
const config = (node.config as WorkflowNodeConfigForNotify) ?? {};
const channel = notifyChannelsMap.get(config.channel as string);
const provider = notifyProvidersMap.get(config.provider);
return (
<Flex className="size-full overflow-hidden" align="center" gap={8}>
<Typography.Text className="flex-1 truncate">{t(channel?.name ?? " ")}</Typography.Text>
<Avatar src={provider?.icon} size="small" />
<Typography.Text className="flex-1 truncate">{t(channel?.name ?? provider?.name ?? " ")}</Typography.Text>
<Typography.Text className="truncate" type="secondary">
{config.subject ?? ""}
</Typography.Text>

View File

@@ -1,14 +1,19 @@
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { forwardRef, memo, useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { RightOutlined as RightOutlinedIcon } from "@ant-design/icons";
import { Button, Form, type FormInstance, Input, Select } from "antd";
import { PlusOutlined as PlusOutlinedIcon, RightOutlined as RightOutlinedIcon } from "@ant-design/icons";
import { Alert, Button, Form, type FormInstance, Input, Select } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
import AccessEditModal from "@/components/access/AccessEditModal";
import AccessSelect from "@/components/access/AccessSelect";
import NotifyProviderSelect from "@/components/provider/NotifyProviderSelect";
import { ACCESS_USAGES, accessProvidersMap, notifyProvidersMap } from "@/domain/provider";
import { notifyChannelsMap } from "@/domain/settings";
import { type WorkflowNodeConfigForNotify } from "@/domain/workflow";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { useNotifyChannelsStore } from "@/stores/notify";
type NotifyNodeConfigFormFieldValues = Partial<WorkflowNodeConfigForNotify>;
@@ -35,6 +40,8 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
({ className, style, disabled, initialValues, onValuesChange }, ref) => {
const { t } = useTranslation();
const { accesses } = useAccessesStore(useZustandShallowSelector("accesses"));
const {
channels,
loadedAtOnce: channelsLoadedAtOnce,
@@ -53,7 +60,11 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
.string({ message: t("workflow_node.notify.form.message.placeholder") })
.min(1, t("workflow_node.notify.form.message.placeholder"))
.max(1000, t("common.errmsg.string_max", { max: 1000 })),
channel: z.string({ message: t("workflow_node.notify.form.channel.placeholder") }).min(1, t("workflow_node.notify.form.channel.placeholder")),
channel: z.string().nullish(),
provider: z.string({ message: t("workflow_node.notify.form.provider.placeholder") }).nonempty(t("workflow_node.notify.form.provider.placeholder")),
providerAccessId: z
.string({ message: t("workflow_node.notify.form.provider_access.placeholder") })
.nonempty(t("workflow_node.notify.form.provider_access.placeholder")),
});
const formRule = createSchemaFieldRule(formSchema);
const { form: formInst, formProps } = useAntdForm({
@@ -61,6 +72,49 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
initialValues: initialValues ?? initFormModel(),
});
const fieldProvider = Form.useWatch<string>("provider", { form: formInst, preserve: true });
const fieldProviderAccessId = Form.useWatch<string>("providerAccessId", formInst);
const [showProvider, setShowProvider] = useState(false);
useEffect(() => {
// 通常情况下每个授权信息只对应一个消息通知提供商,此时无需显示消息通知提供商字段;
// 如果对应多个,则显示。
if (fieldProviderAccessId) {
const access = accesses.find((e) => e.id === fieldProviderAccessId);
const providers = Array.from(notifyProvidersMap.values()).filter((e) => e.provider === access?.provider);
setShowProvider(providers.length > 1);
} else {
setShowProvider(false);
}
}, [accesses, fieldProviderAccessId]);
const handleProviderSelect = (value: string) => {
if (fieldProvider === value) return;
// 切换消息通知提供商时联动授权信息
if (initialValues?.provider === value) {
formInst.setFieldValue("providerAccessId", initialValues?.providerAccessId);
onValuesChange?.(formInst.getFieldsValue(true));
} else {
if (notifyProvidersMap.get(fieldProvider)?.provider !== notifyProvidersMap.get(value)?.provider) {
formInst.setFieldValue("providerAccessId", undefined);
onValuesChange?.(formInst.getFieldsValue(true));
}
}
};
const handleProviderAccessSelect = (value: string) => {
if (fieldProviderAccessId === value) return;
// 切换授权信息时联动消息通知提供商
const access = accesses.find((access) => access.id === value);
const provider = Array.from(notifyProvidersMap.values()).find((provider) => provider.provider === access?.provider);
if (fieldProvider !== provider?.type) {
formInst.setFieldValue("provider", provider?.type);
onValuesChange?.(formInst.getFieldsValue(true));
}
};
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values as NotifyNodeConfigFormFieldValues);
};
@@ -92,7 +146,7 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
<Form.Item className="mb-0">
<label className="mb-1 block">
<div className="flex w-full items-center justify-between gap-4">
<div className="max-w-full grow truncate">{t("workflow_node.notify.form.channel.label")}</div>
<div className="max-w-full grow truncate line-through">{t("workflow_node.notify.form.channel.label")}</div>
<div className="text-right">
<Link className="ant-typography" to="/settings/notification" target="_blank">
<Button size="small" type="link">
@@ -116,6 +170,60 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
/>
</Form.Item>
</Form.Item>
<Form.Item name="provider" label={t("workflow_node.notify.form.provider.label")} hidden={!showProvider} rules={[formRule]}>
<NotifyProviderSelect
disabled={!showProvider}
filter={(record) => {
if (fieldProviderAccessId) {
return accesses.find((e) => e.id === fieldProviderAccessId)?.provider === record.provider;
}
return true;
}}
placeholder={t("workflow_node.notify.form.provider.placeholder")}
showSearch
onSelect={handleProviderSelect}
/>
</Form.Item>
<Form.Item className="mb-0">
<label className="mb-1 block">
<div className="flex w-full items-center justify-between gap-4">
<div className="max-w-full grow truncate">
<span>{t("workflow_node.notify.form.provider_access.label")}</span>
</div>
<div className="text-right">
<AccessEditModal
range="notify-only"
scene="add"
trigger={
<Button size="small" type="link">
{t("workflow_node.notify.form.provider_access.button")}
<PlusOutlinedIcon className="text-xs" />
</Button>
}
afterSubmit={(record) => {
const provider = accessProvidersMap.get(record.provider);
if (provider?.usages?.includes(ACCESS_USAGES.NOTIFICATION)) {
formInst.setFieldValue("providerAccessId", record.id);
}
}}
/>
</div>
</div>
</label>
<Form.Item name="providerAccessId" rules={[formRule]}>
<AccessSelect
filter={(record) => {
const provider = accessProvidersMap.get(record.provider);
return !!provider?.usages?.includes(ACCESS_USAGES.NOTIFICATION);
}}
placeholder={t("workflow_node.notify.form.provider_access.placeholder")}
onChange={handleProviderAccessSelect}
/>
</Form.Item>
</Form.Item>
</Form>
);
}

View File

@@ -201,9 +201,9 @@ export const applyCAProvidersMap: Map<ApplyCAProvider["type"] | string, ApplyCAP
// #region ApplyDNSProvider
/*
注意:如果追加新的常量值,请保持以 ASCII 排序。
NOTICE: If you add new constant, please keep ASCII order.
*/
注意:如果追加新的常量值,请保持以 ASCII 排序。
NOTICE: If you add new constant, please keep ASCII order.
*/
export const APPLY_DNS_PROVIDERS = Object.freeze({
ACMEHTTPREQ: `${ACCESS_PROVIDERS.ACMEHTTPREQ}`,
ALIYUN: `${ACCESS_PROVIDERS.ALIYUN}`, // 兼容旧值,等同于 `ALIYUN_DNS`
@@ -255,9 +255,9 @@ export type ApplyDNSProvider = {
export const applyDNSProvidersMap: Map<ApplyDNSProvider["type"] | string, ApplyDNSProvider> = new Map(
/*
注意:此处的顺序决定显示在前端的顺序。
NOTICE: The following order determines the order displayed at the frontend.
*/
注意:此处的顺序决定显示在前端的顺序。
NOTICE: The following order determines the order displayed at the frontend.
*/
[
[APPLY_DNS_PROVIDERS.ALIYUN_DNS, "provider.aliyun.dns"],
[APPLY_DNS_PROVIDERS.TENCENTCLOUD_DNS, "provider.tencentcloud.dns"],
@@ -302,9 +302,9 @@ export const applyDNSProvidersMap: Map<ApplyDNSProvider["type"] | string, ApplyD
// #region DeployProvider
/*
注意:如果追加新的常量值,请保持以 ASCII 排序。
NOTICE: If you add new constant, please keep ASCII order.
*/
注意:如果追加新的常量值,请保持以 ASCII 排序。
NOTICE: If you add new constant, please keep ASCII order.
*/
export const DEPLOY_PROVIDERS = Object.freeze({
["1PANEL_CONSOLE"]: `${ACCESS_PROVIDERS["1PANEL"]}-console`,
["1PANEL_SITE"]: `${ACCESS_PROVIDERS["1PANEL"]}-site`,
@@ -500,3 +500,38 @@ export const deployProvidersMap: Map<DeployProvider["type"] | string, DeployProv
])
);
// #endregion
// #region NotifyProvider
/*
注意:如果追加新的常量值,请保持以 ASCII 排序。
NOTICE: If you add new constant, please keep ASCII order.
*/
export const NOTIFY_PROVIDERS = Object.freeze({
WEBHOOK: `${ACCESS_PROVIDERS.WEBHOOK}`,
} as const);
export type NotifyProviderType = (typeof APPLY_CA_PROVIDERS)[keyof typeof APPLY_CA_PROVIDERS];
export type NotifyProvider = {
type: NotifyProviderType;
name: string;
icon: string;
provider: AccessProviderType;
};
export const notifyProvidersMap: Map<NotifyProvider["type"] | string, NotifyProvider> = new Map(
/*
注意:此处的顺序决定显示在前端的顺序。
NOTICE: The following order determines the order displayed at the frontend.
*/
[[NOTIFY_PROVIDERS.WEBHOOK]].map(([type]) => [
type,
{
type: type as ApplyCAProviderType,
name: accessProvidersMap.get(type.split("-")[0])!.name,
icon: accessProvidersMap.get(type.split("-")[0])!.icon,
provider: type.split("-")[0] as AccessProviderType,
},
])
);
// #endregion

View File

@@ -3,6 +3,9 @@ import { type ApplyCAProviderType } from "./provider";
export const SETTINGS_NAMES = Object.freeze({
EMAILS: "emails",
NOTIFY_TEMPLATES: "notifyTemplates",
/**
* @deprecated
*/
NOTIFY_CHANNELS: "notifyChannels",
SSL_PROVIDER: "sslProvider",
PERSISTENCE: "persistence",
@@ -38,6 +41,9 @@ export const defaultNotifyTemplate: NotifyTemplate = {
// #endregion
// #region Settings: NotifyChannels
/**
* @deprecated
*/
export const NOTIFY_CHANNELS = Object.freeze({
BARK: "bark",
DINGTALK: "dingtalk",
@@ -53,8 +59,14 @@ export const NOTIFY_CHANNELS = Object.freeze({
WECOM: "wecom",
} as const);
/**
* @deprecated
*/
export type NotifyChannels = (typeof NOTIFY_CHANNELS)[keyof typeof NOTIFY_CHANNELS];
/**
* @deprecated
*/
export type NotifyChannelsSettingsContent = {
/*
注意:如果追加新的类型,请保持以 ASCII 排序。
@@ -116,7 +128,7 @@ export type MattermostNotifyChannelConfig = {
username: string;
password: string;
enabled?: boolean;
}
};
export type PushoverNotifyChannelConfig = {
token: string;
@@ -155,6 +167,9 @@ export type NotifyChannel = {
name: string;
};
/**
* @deprecated
*/
export const notifyChannelsMap: Map<NotifyChannel["type"], NotifyChannel> = new Map(
[
[NOTIFY_CHANNELS.EMAIL, "common.notifier.email"],

View File

@@ -154,9 +154,15 @@ export type WorkflowNodeConfigForDeploy = {
};
export type WorkflowNodeConfigForNotify = {
channel: string;
subject: string;
message: string;
/**
* @deprecated
*/
channel?: string;
provider: string;
providerAccessId: string;
providerConfig?: Record<string, unknown>;
};
export type WorkflowNodeConfigForBranch = never;

View File

@@ -712,9 +712,14 @@
"workflow_node.notify.form.subject.placeholder": "Please enter subject",
"workflow_node.notify.form.message.label": "Message",
"workflow_node.notify.form.message.placeholder": "Please enter message",
"workflow_node.notify.form.channel.label": "Channel",
"workflow_node.notify.form.channel.label": "Channel (Deprecated)",
"workflow_node.notify.form.channel.placeholder": "Please select channel",
"workflow_node.notify.form.channel.button": "Configure",
"workflow_node.notify.form.provider.label": "Notification channel",
"workflow_node.notify.form.provider.placeholder": "Please select notification channel",
"workflow_node.notify.form.provider_access.label": "Notification provider authorization",
"workflow_node.notify.form.provider_access.placeholder": "Please select an authorization of notification provider",
"workflow_node.notify.form.provider_access.button": "Create",
"workflow_node.end.label": "End",

View File

@@ -711,9 +711,14 @@
"workflow_node.notify.form.subject.placeholder": "请输入通知主题",
"workflow_node.notify.form.message.label": "通知内容",
"workflow_node.notify.form.message.placeholder": "请输入通知内容",
"workflow_node.notify.form.channel.label": "通知渠道",
"workflow_node.notify.form.channel.label": "通知渠道(已废弃,请使用「通知渠道授权」字段)",
"workflow_node.notify.form.channel.placeholder": "请选择通知渠道",
"workflow_node.notify.form.channel.button": "去配置",
"workflow_node.notify.form.channel.button": "置",
"workflow_node.notify.form.provider.label": "通知渠道",
"workflow_node.notify.form.provider.placeholder": "请选择通知渠道",
"workflow_node.notify.form.provider_access.label": "通知渠道授权",
"workflow_node.notify.form.provider_access.placeholder": "请选择通知渠道授权",
"workflow_node.notify.form.provider_access.button": "新建",
"workflow_node.end.label": "结束",

View File

@@ -185,10 +185,12 @@ const AccessList = () => {
const handleTabChange = (key: string) => {
setFilters((prev) => ({ ...prev, range: key }));
setPage(1);
};
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, keyword: value }));
setPage(1);
};
const handleReloadClick = () => {
@@ -251,10 +253,10 @@ const AccessList = () => {
key: "ca-only",
label: t("access.props.range.ca_only"),
},
// {
// key: "notify-only",
// label: t("access.props.range.notify_only"),
// },
{
key: "notify-only",
label: t("access.props.range.notify_only"),
},
]}
activeTabKey={filters["range"] as string}
onTabChange={(key) => handleTabChange(key)}

View File

@@ -251,6 +251,7 @@ const CertificateList = () => {
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, keyword: value.trim() }));
setPage(1);
};
const handleReloadClick = () => {

View File

@@ -1,11 +1,14 @@
import { useTranslation } from "react-i18next";
import { Card, Divider } from "antd";
import { Alert, Card, Divider } from "antd";
import NotifyChannels from "@/components/notification/NotifyChannels";
import NotifyTemplate from "@/components/notification/NotifyTemplate";
import { useZustandShallowSelector } from "@/hooks";
import { useNotifyChannelsStore } from "@/stores/notify";
/**
* @deprecated
*/
const SettingsNotification = () => {
const { t } = useTranslation();
@@ -22,6 +25,7 @@ const SettingsNotification = () => {
<Divider />
<Card className="shadow" styles={{ body: loadedAtOnce ? { padding: 0 } : {} }} title={t("settings.notification.channels.card.title")}>
<Alert type="warning" banner message="本页面相关功能即将在后续版本中废弃,请使用「授权管理」页面来管理通知渠道。" />
<NotifyChannels classNames={{ form: "md:max-w-[40rem]" }} />
</Card>
</div>

View File

@@ -281,6 +281,7 @@ const WorkflowList = () => {
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, keyword: value.trim() }));
setPage(1);
};
const handleCreateClick = () => {

View File

@@ -4,6 +4,9 @@ import { create } from "zustand";
import { type NotifyChannelsSettingsContent, SETTINGS_NAMES, type SettingsModel } from "@/domain/settings";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
/**
* @deprecated
*/
export interface NotifyChannelsState {
channels: NotifyChannelsSettingsContent;
loading: boolean;
@@ -14,6 +17,9 @@ export interface NotifyChannelsState {
setChannels: (channels: NotifyChannelsSettingsContent) => Promise<void>;
}
/**
* @deprecated
*/
export const useNotifyChannelsStore = create<NotifyChannelsState>((set, get) => {
let fetcher: Promise<SettingsModel<NotifyChannelsSettingsContent>> | null = null; // 防止多次重复请求
let settings: SettingsModel<NotifyChannelsSettingsContent>; // 记录当前设置的其他字段,保存回数据库时用