feat(ui): display both available and unavailable providers

This commit is contained in:
Fu Diwei
2025-09-05 11:45:53 +08:00
parent c17d27c987
commit fb4dd929ca
5 changed files with 197 additions and 139 deletions
@@ -1,17 +1,17 @@
import { useMemo, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMount } from "ahooks";
import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd";
import { Avatar, Card, Divider, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd";
import Show from "@/components/Show";
import { DEPLOYMENT_CATEGORIES, type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider";
import { useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
import { mergeCls } from "@/utils/css";
import { type SharedPickerProps, usePickerWrapperCols } from "./_shared";
import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared";
export interface DeploymentProviderPickerProps extends SharedPickerProps<DeploymentProvider> {}
export interface DeploymentProviderPickerProps extends SharedPickerProps<DeploymentProvider> {
showAvailable?: boolean;
}
const DeploymentProviderPicker = ({
className,
@@ -19,19 +19,15 @@ const DeploymentProviderPicker = ({
autoFocus,
gap = "middle",
placeholder,
showAvailable = true,
showSearch = true,
onFilter,
onSelect,
}: DeploymentProviderPickerProps) => {
const { t } = useTranslation();
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
const { wrapperElRef, cols } = usePickerWrapperCols(320);
const [isAvailableOnly, setIsAvailableOnly] = useState(true);
const [category, setCategory] = useState<string>(DEPLOYMENT_CATEGORIES.ALL);
const [keyword, setKeyword] = useState<string>();
@@ -42,38 +38,47 @@ const DeploymentProviderPicker = ({
}
});
const providers = useMemo(() => {
return Array.from(deploymentProvidersMap.values())
.filter((provider) => {
if (onFilter) {
return onFilter(provider.type, provider);
}
return true;
})
.filter((provider) => {
if (isAvailableOnly) {
return provider.builtin || accesses.some((access) => access.provider === provider.provider);
}
return true;
})
.filter((provider) => {
const dataSources = usePickerDataSource({
dataSource: Array.from(deploymentProvidersMap.values()),
filters: [
(provider) => {
if (category && category !== DEPLOYMENT_CATEGORIES.ALL) {
return provider.category === category;
}
return true;
})
.filter((provider) => {
if (keyword) {
const value = keyword.toLowerCase();
return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value);
}
},
],
keyword: keyword,
onFilter: onFilter,
deps: [category],
});
return true;
});
}, [onFilter, accesses, isAvailableOnly, category, keyword]);
const renderOption = (provider: DeploymentProvider) => {
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>
);
};
const handleProviderTypeSelect = (value: string) => {
onSelect?.(value);
@@ -87,12 +92,6 @@ const DeploymentProviderPicker = ({
</div>
</Show>
<Flex className="mb-4" justify="end">
<Checkbox checked={isAvailableOnly} onClick={() => setIsAvailableOnly(!isAvailableOnly)}>
{t("provider.text.show_available_provider_only")}
</Checkbox>
</Flex>
<Flex>
<Tabs
defaultActiveKey={DEPLOYMENT_CATEGORIES.ALL}
@@ -121,7 +120,7 @@ const DeploymentProviderPicker = ({
/>
<div className="flex-1">
<Show when={providers.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
<Show when={dataSources.filtered.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",
@@ -130,32 +129,27 @@ const DeploymentProviderPicker = ({
[`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>
);
})}
{(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
</div>
<Show when={showAvailable && dataSources.unavailable.length > 0}>
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("provider.text.unavailable_options")}
</Typography.Text>
</Divider>
<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",
})}
>
{dataSources.unavailable.map((provider) => renderOption(provider))}
</div>
</Show>
</Show>
</div>
</Flex>
@@ -1,17 +1,17 @@
import { useMemo, useRef, useState } from "react";
import { 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 { Avatar, Card, Divider, Empty, 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";
import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared";
export interface NotificationProviderPickerProps extends SharedPickerProps<NotificationProvider> {}
export interface NotificationProviderPickerProps extends SharedPickerProps<NotificationProvider> {
showAvailable?: boolean;
}
const NotificationProviderPicker = ({
className,
@@ -19,19 +19,15 @@ const NotificationProviderPicker = ({
autoFocus,
gap = "middle",
placeholder,
showAvailable = true,
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(() => {
@@ -40,31 +36,37 @@ const NotificationProviderPicker = ({
}
});
const providers = useMemo(() => {
return Array.from(notificationProvidersMap.values())
.filter((provider) => {
if (onFilter) {
return onFilter(provider.type, provider);
}
const dataSources = usePickerDataSource({
dataSource: Array.from(notificationProvidersMap.values()),
keyword: keyword,
onFilter: onFilter,
});
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 renderOption = (provider: NotificationProvider) => {
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>
);
};
const handleProviderTypeSelect = (value: string) => {
onSelect?.(value);
@@ -78,13 +80,7 @@ const NotificationProviderPicker = ({
</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} />}>
<Show when={dataSources.filtered.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",
@@ -93,32 +89,27 @@ const NotificationProviderPicker = ({
[`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>
);
})}
{(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
</div>
<Show when={showAvailable && dataSources.unavailable.length > 0}>
<Divider size="small">
<Typography.Text className="text-xs font-normal" type="secondary">
{t("provider.text.unavailable_options")}
</Typography.Text>
</Divider>
<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",
})}
>
{dataSources.unavailable.map((provider) => renderOption(provider))}
</div>
</Show>
</Show>
</div>
);
+74 -1
View File
@@ -1,7 +1,12 @@
import { useMemo, useRef } from "react";
import { useSize } from "ahooks";
import { useTranslation } from "react-i18next";
import { useMount, useSize } from "ahooks";
import { type SelectProps } from "antd";
import { useZustandShallowSelector } from "@/hooks";
import { useAccessesStore } from "@/stores/access";
type Provider = { type: string; name: string };
export interface SharedSelectProps<T extends Provider>
@@ -40,3 +45,71 @@ export const usePickerWrapperCols = (width: number) => {
cols,
};
};
export const usePickerDataSource = <T extends Provider>({
dataSource,
filters,
keyword,
onFilter,
deps,
}: {
dataSource: T[];
filters?: Array<(option: T) => boolean>;
keyword?: string;
onFilter?: (value: string, option: T) => boolean;
deps?: React.DependencyList;
}) => {
const { t } = useTranslation();
const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"]));
useMount(() => fetchAccesses(false));
const filteredDataSource = useMemo(() => {
return dataSource
.filter((provider) => {
if (onFilter) {
return onFilter(provider.type, provider);
}
return true;
})
.filter((provider) => {
if (filters) {
for (const filter of filters) {
if (!filter(provider)) return false;
}
}
return true;
})
.filter((provider) => {
if (keyword) {
const value = keyword.toLowerCase();
return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value);
}
return true;
});
}, [dataSource, filters, keyword, onFilter, ...(deps ?? [])]);
const availableDataSource = useMemo(() => {
return filteredDataSource.filter((provider) => {
return accesses.some((access) => {
if ("builtin" in provider && provider.builtin) return true;
if ("provider" in provider) return access.provider === provider.provider;
return access.provider === provider.type;
});
});
}, [accesses, filteredDataSource, ...(deps ?? [])]);
const unavailableDataSource = useMemo(() => {
return filteredDataSource.filter((item) => !availableDataSource.includes(item));
}, [filteredDataSource, availableDataSource, ...(deps ?? [])]);
return {
all: dataSource,
filtered: filteredDataSource,
available: availableDataSource,
unavailable: unavailableDataSource,
};
};
+1 -1
View File
@@ -196,5 +196,5 @@
"provider.text.nodata": "No providers available",
"provider.text.default_ca_provider": "(Default) Follow global settings",
"provider.text.show_available_provider_only": "Only display available providers (with added credentials)"
"provider.text.unavailable_options": "The following providers are not available (without added credentials)"
}
+1 -1
View File
@@ -196,5 +196,5 @@
"provider.text.nodata": "暂无提供商",
"provider.text.default_ca_provider": "(默认)不指定,跟随全局设置",
"provider.text.show_available_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)"
"provider.text.unavailable_options": "以下提供商不可用(即未添加过授权凭据)"
}