feat(ui): NotificationProviderPicker

This commit is contained in:
Fu Diwei
2025-09-05 11:45:53 +08:00
parent ba37eeedb5
commit c17d27c987
17 changed files with 489 additions and 349 deletions
@@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Typography, theme } from "antd";
import { Avatar, Select, Typography, theme } from "antd";
import { type ACMEDns01Provider, acmeDns01ProvidersMap } from "@/domain/provider";
export interface ACMEDns01ProviderSelectProps
extends Omit<SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"> {
onFilter?: (value: string, option: ACMEDns01Provider) => boolean;
}
import { type SharedSelectProps } from "./_shared";
export interface ACMEDns01ProviderSelectProps extends SharedSelectProps<ACMEDns01Provider> {}
const ACMEDns01ProviderSelect = ({ onFilter, ...props }: ACMEDns01ProviderSelectProps) => {
const { t } = useTranslation();
@@ -1,37 +1,31 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount, useSize } from "ahooks";
import { useMount } from "ahooks";
import { Avatar, Card, Empty, Input, type InputRef, Tag, Tooltip, Typography } from "antd";
import Show from "@/components/Show";
import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider";
import { mergeCls } from "@/utils/css";
export interface AccessProviderPickerProps {
className?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
gap?: number | "small" | "middle" | "large";
placeholder?: string;
import { type SharedPickerProps, usePickerWrapperCols } from "./_shared";
export interface AccessProviderPickerProps extends SharedPickerProps<AccessProvider> {
showOptionTags?: boolean | { [key in AccessUsageType]?: boolean };
onFilter?: (value: string, option: AccessProvider) => boolean;
onSelect?: (value: string) => void;
}
const AccessProviderPicker = ({ className, style, autoFocus, gap = "middle", placeholder, showOptionTags, onFilter, onSelect }: AccessProviderPickerProps) => {
const AccessProviderPicker = ({
className,
style,
autoFocus,
gap = "middle",
placeholder,
showOptionTags,
showSearch = true,
onFilter,
onSelect,
}: AccessProviderPickerProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLDivElement>(null);
const wrapperSize = useSize(wrapperRef);
const [keyword, setKeyword] = useState<string>();
const keywordInputRef = useRef<InputRef>(null);
useMount(() => {
if (autoFocus) {
setTimeout(() => keywordInputRef.current?.focus(), 1);
}
});
const showOptionTagForDNS = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.DNS] : !!showOptionTags;
}, [showOptionTags]);
@@ -48,6 +42,16 @@ const AccessProviderPicker = ({ className, style, autoFocus, gap = "middle", pla
return showOptionTagForDNS || showOptionTagForHosting || showOptionTagForCA || showOptionTagForNotification;
}, [showOptionTagForDNS, showOptionTagForHosting, showOptionTagForCA, showOptionTagForNotification]);
const { wrapperElRef, cols } = usePickerWrapperCols(showOptionTagAnyhow ? 240 : 200);
const [keyword, setKeyword] = useState<string>();
const keywordInputRef = useRef<InputRef>(null);
useMount(() => {
if (autoFocus) {
setTimeout(() => keywordInputRef.current?.focus(), 1);
}
});
const providers = useMemo(() => {
return Array.from(accessProvidersMap.values())
.filter((provider) => {
@@ -66,86 +70,80 @@ const AccessProviderPicker = ({ className, style, autoFocus, gap = "middle", pla
return true;
});
}, [onFilter, keyword]);
const providerCols = useMemo(() => {
if (!wrapperSize) {
return 1;
}
const cols = Math.floor(wrapperSize.width / (showOptionTagAnyhow ? 240 : 200));
return Math.min(9, Math.max(1, cols));
}, [wrapperSize, showOptionTagAnyhow]);
const handleProviderTypeSelect = (value: string) => {
onSelect?.(value);
};
return (
<div className={className} style={style} ref={wrapperRef}>
<Input.Search ref={keywordInputRef} placeholder={placeholder ?? t("common.text.search")} onChange={(e) => setKeyword(e.target.value.trim())} />
<div className={className} style={style} ref={wrapperElRef}>
<Show when={showSearch}>
<div className="mb-4">
<Input.Search ref={keywordInputRef} placeholder={placeholder ?? t("common.text.search")} onChange={(e) => setKeyword(e.target.value.trim())} />
</div>
</Show>
<div className="mt-4">
<Show when={providers.length > 0} fallback={<Empty description={t("common.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${providerCols}`, {
"gap-4": gap === "large",
"gap-2": gap === "middle",
"gap-1": gap === "small",
[`gap-${+gap || "2"}`]: typeof gap === "number",
})}
>
{providers.map((provider) => {
return (
<div key={provider.type}>
<Card
className={mergeCls("w-full overflow-hidden shadow", provider.builtin ? " cursor-not-allowed" : "", showOptionTagAnyhow ? "h-32" : "h-28")}
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
hoverable
onClick={() => {
if (provider.builtin) {
return;
}
<Show when={providers.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
"gap-4": gap === "large",
"gap-2": gap === "middle",
"gap-1": gap === "small",
[`gap-${+gap || "2"}`]: typeof gap === "number",
})}
>
{providers.map((provider) => {
return (
<div key={provider.type}>
<Card
className={mergeCls("w-full overflow-hidden shadow", provider.builtin ? " cursor-not-allowed" : "", showOptionTagAnyhow ? "h-32" : "h-28")}
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
hoverable
onClick={() => {
if (provider.builtin) {
return;
}
handleProviderTypeSelect(provider.type);
}}
>
<div className="flex size-full flex-col items-center justify-center gap-3 overflow-hidden p-2">
<div className="flex items-center justify-center">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={32} />
</div>
<div className="w-full overflow-hidden text-center">
<div className={mergeCls("w-full truncate", { "mb-1": showOptionTagAnyhow })}>
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
<Typography.Text type={provider.builtin ? "secondary" : void 0}>{t(provider.name) || "\u00A0"}</Typography.Text>
</Tooltip>
</div>
<Show when={showOptionTagAnyhow}>
<div className="origin-top scale-80 whitespace-nowrap" style={{ marginInlineEnd: "-8px" }}>
<Show when={provider.builtin}>
<Tag>{t("access.props.provider.builtin")}</Tag>
</Show>
<Show when={showOptionTagForDNS && provider.usages.includes(ACCESS_USAGES.DNS)}>
<Tag color="#d93f0b99">{t("access.props.provider.usage.dns")}</Tag>
</Show>
<Show when={showOptionTagForHosting && provider.usages.includes(ACCESS_USAGES.HOSTING)}>
<Tag color="#0052cc99">{t("access.props.provider.usage.hosting")}</Tag>
</Show>
<Show when={showOptionTagForCA && provider.usages.includes(ACCESS_USAGES.CA)}>
<Tag color="#0e8a1699">{t("access.props.provider.usage.ca")}</Tag>
</Show>
<Show when={showOptionTagForNotification && provider.usages.includes(ACCESS_USAGES.NOTIFICATION)}>
<Tag color="#1d76db99">{t("access.props.provider.usage.notification")}</Tag>
</Show>
</div>
</Show>
</div>
handleProviderTypeSelect(provider.type);
}}
>
<div className="flex size-full flex-col items-center justify-center gap-3 overflow-hidden p-2">
<div className="flex items-center justify-center">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={32} />
</div>
</Card>
</div>
);
})}
</div>
</Show>
</div>
<div className="w-full overflow-hidden text-center">
<div className={mergeCls("w-full truncate", { "mb-1": showOptionTagAnyhow })}>
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
<Typography.Text type={provider.builtin ? "secondary" : void 0}>{t(provider.name) || "\u00A0"}</Typography.Text>
</Tooltip>
</div>
<Show when={showOptionTagAnyhow}>
<div className="origin-top scale-80 whitespace-nowrap" style={{ marginInlineEnd: "-8px" }}>
<Show when={provider.builtin}>
<Tag>{t("access.props.provider.builtin")}</Tag>
</Show>
<Show when={showOptionTagForDNS && provider.usages.includes(ACCESS_USAGES.DNS)}>
<Tag color="#d93f0b99">{t("access.props.provider.usage.dns")}</Tag>
</Show>
<Show when={showOptionTagForHosting && provider.usages.includes(ACCESS_USAGES.HOSTING)}>
<Tag color="#0052cc99">{t("access.props.provider.usage.hosting")}</Tag>
</Show>
<Show when={showOptionTagForCA && provider.usages.includes(ACCESS_USAGES.CA)}>
<Tag color="#0e8a1699">{t("access.props.provider.usage.ca")}</Tag>
</Show>
<Show when={showOptionTagForNotification && provider.usages.includes(ACCESS_USAGES.NOTIFICATION)}>
<Tag color="#1d76db99">{t("access.props.provider.usage.notification")}</Tag>
</Show>
</div>
</Show>
</div>
</div>
</Card>
</div>
);
})}
</div>
</Show>
</div>
);
};
@@ -1,14 +1,14 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Tag, Typography, theme } from "antd";
import { Avatar, Select, Tag, Typography, theme } from "antd";
import Show from "@/components/Show";
import { ACCESS_USAGES, type AccessProvider, type AccessUsageType, accessProvidersMap } from "@/domain/provider";
export interface AccessProviderSelectProps
extends Omit<SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"> {
import { type SharedSelectProps } from "./_shared";
export interface AccessProviderSelectProps extends SharedSelectProps<AccessProvider> {
showOptionTags?: boolean | { [key in AccessUsageType]?: boolean };
onFilter?: (value: string, option: AccessProvider) => boolean;
}
const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProviderSelectProps = { showOptionTags: true }) => {
@@ -16,6 +16,19 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv
const { token: themeToken } = theme.useToken();
const showOptionTagForDNS = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.DNS] : !!showOptionTags;
}, [showOptionTags]);
const showOptionTagForHosting = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.HOSTING] : !!showOptionTags;
}, [showOptionTags]);
const showOptionTagForCA = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.CA] : !!showOptionTags;
}, [showOptionTags]);
const showOptionTagForNotification = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.NOTIFICATION] : !!showOptionTags;
}, [showOptionTags]);
const options = useMemo<Array<{ key: string; value: string; label: string; data: AccessProvider }>>(() => {
return Array.from(accessProvidersMap.values())
.filter((provider) => {
@@ -34,19 +47,6 @@ const AccessProviderSelect = ({ showOptionTags, onFilter, ...props }: AccessProv
}));
}, [onFilter]);
const showOptionTagForDNS = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.DNS] : !!showOptionTags;
}, [showOptionTags]);
const showOptionTagForHosting = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.HOSTING] : !!showOptionTags;
}, [showOptionTags]);
const showOptionTagForCA = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.CA] : !!showOptionTags;
}, [showOptionTags]);
const showOptionTagForNotification = useMemo(() => {
return typeof showOptionTags === "object" ? !!showOptionTags?.[ACCESS_USAGES.NOTIFICATION] : !!showOptionTags;
}, [showOptionTags]);
const renderOption = (key: string) => {
const provider = accessProvidersMap.get(key) ?? ({ type: "", name: "", icon: "", usages: [] } as unknown as AccessProvider);
return (
@@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Typography, theme } from "antd";
import { Avatar, Select, Typography, theme } from "antd";
import { type CAProvider, caProvidersMap } from "@/domain/provider";
export interface CAProviderSelectProps
extends Omit<SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"> {
onFilter?: (value: string, option: CAProvider) => boolean;
}
import { type SharedSelectProps } from "./_shared";
export interface CAProviderSelectProps extends SharedSelectProps<CAProvider> {}
const CAProviderSelect = ({ onFilter, ...props }: CAProviderSelectProps) => {
const { t } = useTranslation();
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount, useSize } from "ahooks";
import { useMount } from "ahooks";
import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd";
import Show from "@/components/Show";
@@ -9,24 +9,26 @@ import { useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { mergeCls } from "@/utils/css";
export interface DeploymentProviderPickerProps {
className?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
gap?: number | "small" | "middle" | "large";
placeholder?: string;
onFilter?: (value: string, option: DeploymentProvider) => boolean;
onSelect?: (value: string) => void;
}
import { type SharedPickerProps, usePickerWrapperCols } from "./_shared";
const DeploymentProviderPicker = ({ className, style, autoFocus, gap = "middle", placeholder, onFilter, onSelect }: DeploymentProviderPickerProps) => {
export interface DeploymentProviderPickerProps extends SharedPickerProps<DeploymentProvider> {}
const DeploymentProviderPicker = ({
className,
style,
autoFocus,
gap = "middle",
placeholder,
showSearch = true,
onFilter,
onSelect,
}: DeploymentProviderPickerProps) => {
const { t } = useTranslation();
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
const wrapperRef = useRef<HTMLDivElement>(null);
const wrapperSize = useSize(wrapperRef);
const { wrapperElRef, cols } = usePickerWrapperCols(320);
const [isAvailableOnly, setIsAvailableOnly] = useState(true);
@@ -72,99 +74,91 @@ const DeploymentProviderPicker = ({ className, style, autoFocus, gap = "middle",
return true;
});
}, [onFilter, accesses, isAvailableOnly, category, keyword]);
const providerCols = useMemo(() => {
if (!wrapperSize) {
return 1;
}
const cols = Math.floor(wrapperSize.width / 320);
return Math.min(9, Math.max(1, cols));
}, [wrapperSize]);
const handleProviderTypeSelect = (value: string) => {
onSelect?.(value);
};
return (
<div className={className} style={style} ref={wrapperRef}>
<Input.Search ref={keywordInputRef} placeholder={placeholder ?? t("common.text.search")} onChange={(e) => setKeyword(e.target.value.trim())} />
<div className={className} style={style} ref={wrapperElRef}>
<Show when={showSearch}>
<div className="mb-4">
<Input.Search ref={keywordInputRef} placeholder={placeholder ?? t("common.text.search")} onChange={(e) => setKeyword(e.target.value.trim())} />
</div>
</Show>
<div className="mt-4">
<Flex justify="end">
<Checkbox checked={isAvailableOnly} onClick={() => setIsAvailableOnly(!isAvailableOnly)}>
{t("provider.text.show_available_hosting_provider_only")}
</Checkbox>
</Flex>
</div>
<Flex className="mb-4" justify="end">
<Checkbox checked={isAvailableOnly} onClick={() => setIsAvailableOnly(!isAvailableOnly)}>
{t("provider.text.show_available_provider_only")}
</Checkbox>
</Flex>
<div className="mt-4">
<Flex>
<Tabs
defaultActiveKey={DEPLOYMENT_CATEGORIES.ALL}
items={[
DEPLOYMENT_CATEGORIES.ALL,
DEPLOYMENT_CATEGORIES.CDN,
DEPLOYMENT_CATEGORIES.STORAGE,
DEPLOYMENT_CATEGORIES.LOADBALANCE,
DEPLOYMENT_CATEGORIES.FIREWALL,
DEPLOYMENT_CATEGORIES.AV,
DEPLOYMENT_CATEGORIES.ACCELERATOR,
DEPLOYMENT_CATEGORIES.APIGATEWAY,
DEPLOYMENT_CATEGORIES.SERVERLESS,
DEPLOYMENT_CATEGORIES.WEBSITE,
DEPLOYMENT_CATEGORIES.SSL,
DEPLOYMENT_CATEGORIES.NAS,
DEPLOYMENT_CATEGORIES.OTHER,
].map((key) => ({
key: key,
label: t(`provider.category.${key}`),
}))}
size="small"
tabBarStyle={{ marginLeft: "-1rem" }}
tabPosition="left"
onChange={(key) => setCategory(key)}
/>
<Flex>
<Tabs
defaultActiveKey={DEPLOYMENT_CATEGORIES.ALL}
items={[
DEPLOYMENT_CATEGORIES.ALL,
DEPLOYMENT_CATEGORIES.CDN,
DEPLOYMENT_CATEGORIES.STORAGE,
DEPLOYMENT_CATEGORIES.LOADBALANCE,
DEPLOYMENT_CATEGORIES.FIREWALL,
DEPLOYMENT_CATEGORIES.AV,
DEPLOYMENT_CATEGORIES.ACCELERATOR,
DEPLOYMENT_CATEGORIES.APIGATEWAY,
DEPLOYMENT_CATEGORIES.SERVERLESS,
DEPLOYMENT_CATEGORIES.WEBSITE,
DEPLOYMENT_CATEGORIES.SSL,
DEPLOYMENT_CATEGORIES.NAS,
DEPLOYMENT_CATEGORIES.OTHER,
].map((key) => ({
key: key,
label: t(`provider.category.${key}`),
}))}
size="small"
tabBarStyle={{ marginLeft: "-1rem" }}
tabPosition="left"
onChange={(key) => setCategory(key)}
/>
<div className="flex-1">
<Show when={providers.length > 0} fallback={<Empty description={t("common.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${providerCols}`, {
"gap-4": gap === "large",
"gap-2": gap === "middle",
"gap-1": gap === "small",
[`gap-${+gap || "2"}`]: typeof gap === "number",
})}
>
{providers.map((provider) => {
return (
<div key={provider.type}>
<Card
className="h-16 w-full overflow-hidden shadow"
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
hoverable
onClick={() => {
handleProviderTypeSelect(provider.type);
}}
>
<div className="flex size-full items-center gap-4 overflow-hidden">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
<div className="flex-1 overflow-hidden">
<div className="line-clamp-2 max-w-full">
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
</Tooltip>
</div>
<div className="flex-1">
<Show when={providers.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
"gap-4": gap === "large",
"gap-2": gap === "middle",
"gap-1": gap === "small",
[`gap-${+gap || "2"}`]: typeof gap === "number",
})}
>
{providers.map((provider) => {
return (
<div key={provider.type}>
<Card
className="h-16 w-full overflow-hidden shadow"
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
hoverable
onClick={() => {
handleProviderTypeSelect(provider.type);
}}
>
<div className="flex size-full items-center gap-4 overflow-hidden">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
<div className="flex-1 overflow-hidden">
<div className="line-clamp-2 max-w-full">
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
</Tooltip>
</div>
</div>
</Card>
</div>
);
})}
</div>
</Show>
</div>
</Flex>
</div>
</div>
</Card>
</div>
);
})}
</div>
</Show>
</div>
</Flex>
</div>
);
};
@@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Typography, theme } from "antd";
import { Avatar, Select, Typography, theme } from "antd";
import { type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider";
export interface DeploymentProviderSelectProps
extends Omit<SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"> {
onFilter?: (value: string, option: DeploymentProvider) => boolean;
}
import { type SharedSelectProps } from "./_shared";
export interface DeploymentProviderSelectProps extends SharedSelectProps<DeploymentProvider> {}
const DeploymentProviderSelect = ({ onFilter, ...props }: DeploymentProviderSelectProps) => {
const { t } = useTranslation();
@@ -0,0 +1,127 @@
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tooltip, Typography } from "antd";
import Show from "@/components/Show";
import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider";
import { useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { mergeCls } from "@/utils/css";
import { type SharedPickerProps, usePickerWrapperCols } from "./_shared";
export interface NotificationProviderPickerProps extends SharedPickerProps<NotificationProvider> {}
const NotificationProviderPicker = ({
className,
style,
autoFocus,
gap = "middle",
placeholder,
showSearch = true,
onFilter,
onSelect,
}: NotificationProviderPickerProps) => {
const { t } = useTranslation();
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
const { wrapperElRef, cols } = usePickerWrapperCols(320);
const [isAvailableOnly, setIsAvailableOnly] = useState(true);
const [keyword, setKeyword] = useState<string>();
const keywordInputRef = useRef<InputRef>(null);
useMount(() => {
if (autoFocus) {
setTimeout(() => keywordInputRef.current?.focus(), 1);
}
});
const providers = useMemo(() => {
return Array.from(notificationProvidersMap.values())
.filter((provider) => {
if (onFilter) {
return onFilter(provider.type, provider);
}
return true;
})
.filter((provider) => {
if (isAvailableOnly) {
return accesses.some((access) => access.provider === provider.provider);
}
return true;
})
.filter((provider) => {
if (keyword) {
const value = keyword.toLowerCase();
return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value);
}
return true;
});
}, [onFilter, accesses, isAvailableOnly, keyword]);
const handleProviderTypeSelect = (value: string) => {
onSelect?.(value);
};
return (
<div className={className} style={style} ref={wrapperElRef}>
<Show when={showSearch}>
<div className="mb-4">
<Input.Search ref={keywordInputRef} placeholder={placeholder ?? t("common.text.search")} onChange={(e) => setKeyword(e.target.value.trim())} />
</div>
</Show>
<Flex className="mb-4" justify="end">
<Checkbox checked={isAvailableOnly} onClick={() => setIsAvailableOnly(!isAvailableOnly)}>
{t("provider.text.show_available_provider_only")}
</Checkbox>
</Flex>
<Show when={providers.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<div
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
"gap-4": gap === "large",
"gap-2": gap === "middle",
"gap-1": gap === "small",
[`gap-${+gap || "2"}`]: typeof gap === "number",
})}
>
{providers.map((provider) => {
return (
<div key={provider.type}>
<Card
className="h-16 w-full overflow-hidden shadow"
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
hoverable
onClick={() => {
handleProviderTypeSelect(provider.type);
}}
>
<div className="flex size-full items-center gap-4 overflow-hidden">
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
<div className="flex-1 overflow-hidden">
<div className="line-clamp-2 max-w-full">
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
</Tooltip>
</div>
</div>
</div>
</Card>
</div>
);
})}
</div>
</Show>
</div>
);
};
export default NotificationProviderPicker;
@@ -1,13 +1,12 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Select, type SelectProps, Typography, theme } from "antd";
import { Avatar, Select, Typography, theme } from "antd";
import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider";
export interface NotificationProviderSelectProps
extends Omit<SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"> {
onFilter?: (value: string, option: NotificationProvider) => boolean;
}
import { type SharedSelectProps } from "./_shared";
export interface NotificationProviderSelectProps extends SharedSelectProps<NotificationProvider> {}
const NotificationProviderSelect = ({ onFilter, ...props }: NotificationProviderSelectProps) => {
const { t } = useTranslation();
+42
View File
@@ -0,0 +1,42 @@
import { useMemo, useRef } from "react";
import { useSize } from "ahooks";
import { type SelectProps } from "antd";
type Provider = { type: string; name: string };
export interface SharedSelectProps<T extends Provider>
extends Omit<SelectProps, "filterOption" | "filterSort" | "labelRender" | "options" | "optionFilterProp" | "optionLabelProp" | "optionRender"> {
className?: string;
style?: React.CSSProperties;
onFilter?: (value: string, option: T) => boolean;
}
export interface SharedPickerProps<T extends Provider> {
className?: string;
style?: React.CSSProperties;
autoFocus?: boolean;
gap?: number | "small" | "middle" | "large";
placeholder?: string;
showSearch?: boolean;
onFilter?: (value: string, option: T) => boolean;
onSelect?: (value: string) => void;
}
export const usePickerWrapperCols = (width: number) => {
const wrapperElRef = useRef<HTMLDivElement>(null);
const wrapperSize = useSize(wrapperElRef);
const cols = useMemo(() => {
if (!wrapperSize) {
return 1;
}
const cols = Math.floor(wrapperSize.width / width);
return Math.min(9, Math.max(1, cols));
}, [wrapperSize, width]);
return {
wrapperElRef,
cols,
};
};
@@ -28,7 +28,7 @@ const BizDeployNodeConfigDrawer = ({ node, ...props }: BizDeployNodeConfigDrawer
return (
<NodeConfigDrawer
anchor={fieldProvider ? { items: BizDeployNodeConfigForm.getAnchorItems({ i18n }) } : false}
footer={fieldProvider ? true : false}
footer={fieldProvider ? void 0 : false}
form={formInst}
node={node}
{...props}
@@ -467,7 +467,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
const handleProviderSelect = (value?: string | undefined) => {
// 切换部署目标时重置表单,避免其他部署目标的配置字段影响当前部署目标
if (initialValues?.provider === value) {
formInst.resetFields();
formInst.resetFields(["providerConfig"]);
} else {
const oldValues = formInst.getFieldsValue();
const newValues: Record<string, unknown> = {};
@@ -537,10 +537,7 @@ const BizDeployNodeConfigForm = ({ node, ...props }: BizDeployNodeConfigFormProp
showSearch
onFilter={(_, option) => {
if (option.reserve) return false;
if (fieldProvider) return deploymentProvidersMap.get(fieldProvider)?.provider === option.provider;
const provider = accessProvidersMap.get(option.provider);
return !!provider?.usages?.includes(ACCESS_USAGES.HOSTING);
return deploymentProvidersMap.get(fieldProvider)?.provider === option.provider;
}}
/>
</Form.Item>
@@ -23,11 +23,12 @@ const BizNotifyNodeConfigDrawer = ({ node, ...props }: BizNotifyNodeConfigDrawer
const [formInst] = Form.useForm();
const fieldProvider = Form.useWatch<string>("provider", { form: formInst, preserve: true });
return (
<NodeConfigDrawer
anchor={{
items: BizNotifyNodeConfigForm.getAnchorItems({ i18n }),
}}
anchor={fieldProvider ? { items: BizNotifyNodeConfigForm.getAnchorItems({ i18n }) } : false}
footer={fieldProvider ? void 0 : false}
form={formInst}
node={node}
{...props}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { getI18n, useTranslation } from "react-i18next";
import { type FlowNodeEntity, getNodeForm } from "@flowgram.ai/fixed-layout-editor";
import { IconPlus } from "@tabler/icons-react";
@@ -8,7 +8,9 @@ import { z } from "zod";
import AccessEditDrawer from "@/components/access/AccessEditDrawer";
import AccessSelect from "@/components/access/AccessSelect";
import NotificationProviderPicker from "@/components/provider/NotificationProviderPicker";
import NotificationProviderSelect from "@/components/provider/NotificationProviderSelect";
import Show from "@/components/Show";
import { ACCESS_USAGES, NOTIFICATION_PROVIDERS, accessProvidersMap, notificationProvidersMap } from "@/domain/provider";
import { type WorkflowNodeConfigForBizNotify, defaultNodeConfigForBizNotify } from "@/domain/workflow";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
@@ -79,134 +81,113 @@ const BizNotifyNodeConfigForm = ({ node, ...props }: BizNotifyNodeConfigFormProp
}
}, [fieldProvider]);
const [showProvider, setShowProvider] = useState(false);
useEffect(() => {
// 通常情况下每个授权信息只对应一个消息通知提供商,此时无需显示消息通知提供商字段;
// 如果对应多个,则显示。
if (fieldProviderAccessId) {
const access = accesses.find((e) => e.id === fieldProviderAccessId);
const providers = Array.from(notificationProvidersMap.values()).filter((e) => e.provider === access?.provider);
setShowProvider(providers.length > 1);
} else {
setShowProvider(false);
}
}, [accesses, fieldProviderAccessId]);
const handleProviderSelect = (value: string) => {
// 切换消息通知提供商时联动授权信息
if (initialValues?.provider === value) {
formInst.setFieldValue("providerAccessId", initialValues?.providerAccessId);
} else {
if (notificationProvidersMap.get(fieldProvider)?.provider !== notificationProvidersMap.get(value)?.provider) {
formInst.setFieldValue("providerAccessId", void 0);
}
}
const handleProviderPick = (value: string) => {
formInst.setFieldValue("provider", value);
};
const handleProviderAccessSelect = (value: string) => {
// 切换授权信息时联动消息通知提供商
const access = accesses.find((access) => access.id === value);
const provider = Array.from(notificationProvidersMap.values()).find((provider) => provider.provider === access?.provider);
if (fieldProvider !== provider?.type) {
formInst.setFieldValue("provider", provider?.type);
const handleProviderSelect = (value?: string | undefined) => {
// 切换通知渠道时重置表单,避免其他通知渠道的配置字段影响当前通知渠道
if (initialValues?.provider === value) {
formInst.resetFields(["providerConfig"]);
} else {
if (notificationProvidersMap.get(fieldProvider)?.provider !== notificationProvidersMap.get(value!)?.provider) {
formInst.setFieldValue("providerAccessId", void 0);
}
}
};
return (
<NodeFormContextProvider value={{ node }}>
<Form {...formProps} clearOnDestroy={true} form={formInst} layout="vertical" preserve={false} scrollToFirstError>
<div id="parameters" data-anchor="parameters">
<Form.Item name="subject" label={t("workflow_node.notify.form.subject.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.notify.form.subject.placeholder")} />
</Form.Item>
<Show when={!fieldProvider}>
<NotificationProviderPicker autoFocus placeholder={t("workflow_node.notify.form.provider.search.placeholder")} onSelect={handleProviderPick} />
</Show>
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
</Form.Item>
</div>
<div style={{ display: fieldProvider ? "block" : "none" }}>
<div id="parameters" data-anchor="parameters">
<Form.Item name="subject" label={t("workflow_node.notify.form.subject.label")} rules={[formRule]}>
<Input placeholder={t("workflow_node.notify.form.subject.placeholder")} />
</Form.Item>
<div id="channel" data-anchor="channel">
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.notify.form_anchor.channel.title")}
</Typography.Text>
</Divider>
<Form.Item name="message" label={t("workflow_node.notify.form.message.label")} rules={[formRule]}>
<Input.TextArea autoSize={{ minRows: 3, maxRows: 10 }} placeholder={t("workflow_node.notify.form.message.placeholder")} />
</Form.Item>
</div>
<Form.Item name="provider" label={t("workflow_node.notify.form.provider.label")} hidden={!showProvider} rules={[formRule]}>
<NotificationProviderSelect
disabled={!showProvider}
placeholder={t("workflow_node.notify.form.provider.placeholder")}
showSearch
onFilter={(_, option) => {
if (fieldProviderAccessId) {
return accesses.find((e) => e.id === fieldProviderAccessId)?.provider === option.provider;
}
<div id="channel" data-anchor="channel">
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.notify.form_anchor.channel.title")}
</Typography.Text>
</Divider>
return true;
}}
onSelect={handleProviderSelect}
/>
</Form.Item>
<Form.Item label={t("workflow_node.notify.form.provider_access.label")}>
<div className="absolute -top-[6px] right-0 -translate-y-full">
<AccessEditDrawer
mode="create"
trigger={
<Button size="small" type="link">
{t("workflow_node.notify.form.provider_access.button")}
<IconPlus size="1.25em" />
</Button>
}
usage="notification"
afterSubmit={(record) => {
const provider = accessProvidersMap.get(record.provider);
if (provider?.usages?.includes(ACCESS_USAGES.NOTIFICATION)) {
formInst.setFieldValue("providerAccessId", record.id);
handleProviderAccessSelect(record.id);
}
}}
/>
</div>
<Form.Item name="providerAccessId" noStyle rules={[formRule]}>
<AccessSelect
placeholder={t("workflow_node.notify.form.provider_access.placeholder")}
<Form.Item name="provider" label={t("workflow_node.notify.form.provider.label")} rules={[formRule]}>
<NotificationProviderSelect
allowClear
disabled={!!initialValues?.provider}
placeholder={t("workflow_node.notify.form.provider.placeholder")}
showSearch
onChange={handleProviderAccessSelect}
onFilter={(_, option) => {
if (option.reserve !== "notif") return false;
const provider = accessProvidersMap.get(option.provider);
return !!provider?.usages?.includes(ACCESS_USAGES.NOTIFICATION);
}}
onSelect={handleProviderSelect}
onClear={handleProviderSelect}
/>
</Form.Item>
</Form.Item>
<FormNestedFieldsContextProvider value={{ parentNamePath: "providerConfig" }}>
{NestedProviderConfigFields && <NestedProviderConfigFields />}
</FormNestedFieldsContextProvider>
</div>
<div id="strategy" data-anchor="strategy">
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.notify.form_anchor.strategy.title")}
</Typography.Text>
</Divider>
<Form.Item label={t("workflow_node.notify.form.skip_on_all_prev_skipped.label")}>
<Flex align="center" gap={8} wrap="wrap">
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.prefix")}</div>
<Form.Item name="skipOnAllPrevSkipped" noStyle rules={[formRule]}>
<Switch
checkedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.on")}
unCheckedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.off")}
<Form.Item label={t("workflow_node.notify.form.provider_access.label")}>
<div className="absolute -top-[6px] right-0 -translate-y-full">
<AccessEditDrawer
mode="create"
trigger={
<Button size="small" type="link">
{t("workflow_node.notify.form.provider_access.button")}
<IconPlus size="1.25em" />
</Button>
}
usage="notification"
afterSubmit={(record) => {
const provider = accessProvidersMap.get(record.provider);
if (provider?.usages?.includes(ACCESS_USAGES.NOTIFICATION)) {
formInst.setFieldValue("providerAccessId", record.id);
}
}}
/>
</div>
<Form.Item name="providerAccessId" noStyle rules={[formRule]}>
<AccessSelect
placeholder={t("workflow_node.notify.form.provider_access.placeholder")}
showSearch
onFilter={(_, option) => {
if (option.reserve !== "notif") return false;
return notificationProvidersMap.get(fieldProvider)?.provider === option.provider;
}}
/>
</Form.Item>
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.suffix")}</div>
</Flex>
</Form.Item>
</Form.Item>
<FormNestedFieldsContextProvider value={{ parentNamePath: "providerConfig" }}>
{NestedProviderConfigFields && <NestedProviderConfigFields />}
</FormNestedFieldsContextProvider>
</div>
<div id="strategy" data-anchor="strategy">
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("workflow_node.notify.form_anchor.strategy.title")}
</Typography.Text>
</Divider>
<Form.Item label={t("workflow_node.notify.form.skip_on_all_prev_skipped.label")}>
<Flex align="center" gap={8} wrap="wrap">
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.prefix")}</div>
<Form.Item name="skipOnAllPrevSkipped" noStyle rules={[formRule]}>
<Switch
checkedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.on")}
unCheckedChildren={t("workflow_node.notify.form.skip_on_all_prev_skipped.switch.off")}
/>
</Form.Item>
<div>{t("workflow_node.notify.form.skip_on_all_prev_skipped.suffix")}</div>
</Flex>
</Form.Item>
</div>
</div>
</Form>
</NodeFormContextProvider>
+2 -1
View File
@@ -194,6 +194,7 @@
"provider.category.nas": "NAS",
"provider.category.other": "Other",
"provider.text.nodata": "No providers available",
"provider.text.default_ca_provider": "(Default) Follow global settings",
"provider.text.show_available_hosting_provider_only": "Only display available providers (with added credentials)"
"provider.text.show_available_provider_only": "Only display available providers (with added credentials)"
}
@@ -147,9 +147,9 @@
"workflow_node.deploy.form.certificate_output_node_id.label": "Certificate to deploy",
"workflow_node.deploy.form.certificate_output_node_id.placeholder": "Please select certificate to deploy",
"workflow_node.deploy.form.certificate_output_node_id.help": "Notes: The certificate to be deployed comes from the previous nodes of application or upload.",
"workflow_node.deploy.form.provider.label": "Deploy target",
"workflow_node.deploy.form.provider.placeholder": "Please select deploy target",
"workflow_node.deploy.form.provider.search.placeholder": "Search deploy target ...",
"workflow_node.deploy.form.provider.label": "Deployment target",
"workflow_node.deploy.form.provider.placeholder": "Please select deployment target",
"workflow_node.deploy.form.provider.search.placeholder": "Search deployment target ...",
"workflow_node.deploy.form.provider_access.label": "Hosting provider credential",
"workflow_node.deploy.form.provider_access.placeholder": "Please select an credential of Hosting provider",
"workflow_node.deploy.form.provider_access.tooltip": "Used to invoke API during deployment.",
@@ -997,6 +997,7 @@
"workflow_node.notify.form.message.placeholder": "Please enter message",
"workflow_node.notify.form.provider.label": "Notification channel",
"workflow_node.notify.form.provider.placeholder": "Please select notification channel",
"workflow_node.notify.form.provider.search.placeholder": "Search notification channel ...",
"workflow_node.notify.form.provider_access.label": "Notification provider credential",
"workflow_node.notify.form.provider_access.placeholder": "Please select an credential of notification provider",
"workflow_node.notify.form.provider_access.button": "Create",
+2 -1
View File
@@ -194,6 +194,7 @@
"provider.category.nas": "NAS",
"provider.category.other": "其他",
"provider.text.nodata": "暂无提供商",
"provider.text.default_ca_provider": "(默认)不指定,跟随全局设置",
"provider.text.show_available_hosting_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)"
"provider.text.show_available_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)"
}
@@ -995,6 +995,7 @@
"workflow_node.notify.form.message.placeholder": "请输入通知内容",
"workflow_node.notify.form.provider.label": "通知渠道",
"workflow_node.notify.form.provider.placeholder": "请选择通知渠道",
"workflow_node.notify.form.provider.search.placeholder": "搜索通知渠道……",
"workflow_node.notify.form.provider_access.label": "通知渠道授权",
"workflow_node.notify.form.provider_access.placeholder": "请选择通知渠道授权",
"workflow_node.notify.form.provider_access.button": "新建",