feat: support plan config sync upstream and edit folding support

This commit is contained in:
Zhang Minghan 2024-02-28 13:54:19 +08:00
parent 7432ae6714
commit 7ba52d62d3
12 changed files with 393 additions and 101 deletions

View File

@ -2,6 +2,7 @@ import { Plan } from "@/api/types";
import axios from "axios";
import { CommonResponse } from "@/api/common.ts";
import { getErrorMessage } from "@/utils/base.ts";
import { getApiPlans } from "@/api/v1.ts";
export type PlanConfig = {
enabled: boolean;
@ -24,6 +25,13 @@ export async function getPlanConfig(): Promise<PlanConfig> {
}
}
export async function getExternalPlanConfig(
endpoint: string,
): Promise<PlanConfig> {
const response = await getApiPlans({ endpoint });
return { enabled: response.length > 0, plans: response };
}
export async function setPlanConfig(
config: PlanConfig,
): Promise<CommonResponse> {

35
app/src/admin/hook.tsx Normal file
View File

@ -0,0 +1,35 @@
import { useMemo, useState } from "react";
import { getUniqueList } from "@/utils/base.ts";
import { defaultChannelModels } from "@/admin/channel.ts";
import { getApiModels } from "@/api/v1.ts";
import { useEffectAsync } from "@/utils/hook.ts";
export const useSupportModels = () => {
const [supportModels, setSupportModels] = useState<string[]>([]);
const update = async () => {
const models = await getApiModels();
setSupportModels(models.data);
};
useEffectAsync(update, []);
return {
supportModels,
update,
};
};
export const useChannelModels = () => {
const { supportModels, update } = useSupportModels();
const channelModels = useMemo(
() => getUniqueList([...supportModels, ...defaultChannelModels]),
[supportModels],
);
return {
channelModels,
update,
};
};

View File

@ -81,6 +81,32 @@
display: flex;
flex-direction: column;
&.stacked {
flex-direction: row;
.plan-editor-row {
margin-bottom: 0;
flex-grow: 1;
margin-right: 1rem;
.plan-editor-label {
min-width: 0;
margin-right: 0.75rem;
flex-shrink: 0;
@media (max-width: 768px) {
svg {
display: none;
}
}
svg {
margin: 0 0.25rem;
}
}
}
}
.plan-editor-row > p {
min-width: 4.25rem;
}

View File

@ -14,6 +14,15 @@ import { NumberInput } from "@/components/ui/number-input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Alert, AlertDescription } from "./ui/alert";
import { AlertCircle } from "lucide-react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export enum popupTypes {
Text = "text",
@ -170,4 +179,60 @@ function PopupDialog(props: PopupDialogProps) {
);
}
type PopupAlertDialogProps = {
title: string;
description?: string;
open: boolean;
setOpen: (open: boolean) => void;
cancelLabel?: string;
confirmLabel?: string;
destructive?: boolean;
disabled?: boolean;
onSubmit?: () => Promise<boolean>;
};
export function PopupAlertDialog({
title,
description,
open,
setOpen,
cancelLabel,
confirmLabel,
destructive,
disabled,
onSubmit,
}: PopupAlertDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description && (
<AlertDialogDescription>{description}</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel || t("cancel")}</AlertDialogCancel>
<Button
disabled={disabled}
variant={destructive ? `destructive` : `default`}
loading={true}
onClick={async () => {
if (!onSubmit) return;
const status: boolean = await onSubmit();
if (status) {
setOpen(false);
}
}}
>
{confirmLabel || t("confirm")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export default PopupDialog;

View File

@ -65,7 +65,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import Tips from "@/components/Tips.tsx";
import { getQuerySelector, scrollUp } from "@/utils/dom.ts";
import PopupDialog, { popupTypes } from "@/components/PopupDialog.tsx";
import { getApiCharge, getApiModels, getV1Path } from "@/api/v1.ts";
import { getApiCharge, getV1Path } from "@/api/v1.ts";
import {
Dialog,
DialogContent,
@ -77,6 +77,7 @@ import {
import { getUniqueList, parseNumber } from "@/utils/base.ts";
import { defaultChannelModels } from "@/admin/channel.ts";
import { getPricing } from "@/admin/datasets/charge.ts";
import { useSupportModels } from "@/admin/hook.tsx";
const initialState: ChargeProps = {
id: -1,
@ -199,7 +200,7 @@ function SyncDialog({
const pricing = getPricing(currency);
setSiteCharge(pricing);
setSiteOpen(true)
setSiteOpen(true);
return true;
}}
@ -718,7 +719,7 @@ function ChargeWidget() {
const [form, dispatch] = useReducer(reducer, initialState);
const [loading, setLoading] = useState(false);
const [supportModels, setSupportModels] = useState<string[]>([]);
const { supportModels, update } = useSupportModels();
const currentModels = useMemo(() => {
return data.flatMap((charge) => charge.models);
@ -735,18 +736,17 @@ function ChargeWidget() {
);
}, [loading, supportModels, usedModels]);
async function refresh() {
async function refresh(ignoreUpdate?: boolean) {
setLoading(true);
const resp = await listCharge();
const models = await getApiModels();
setSupportModels(models.data);
if (!ignoreUpdate) await update();
setLoading(false);
toastState(toast, t, resp);
setData(resp.data);
}
useEffectAsync(refresh, []);
useEffectAsync(async () => await refresh(true), []);
return (
<div className={`charge-widget`}>

View File

@ -131,7 +131,7 @@ function SyncDialog({ dispatch, open, setOpen }: SyncDialogProps) {
<Input
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder={t("admin.channels.secret-placeholder")}
placeholder={t("admin.channels.sync-secret-placeholder")}
/>
</div>
</div>

View File

@ -88,6 +88,12 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const child = useMemo(() => {
if (asChild) return children;
if (size === "icon" || size === "icon-sm") {
if (loading && working) {
return <Loader2 className={`animate-spin w-4 h-4`} />;
}
}
return (
<>
{loading && working && (

View File

@ -549,7 +549,12 @@
"item-models-search-placeholder": "搜索模型 ID",
"item-models-placeholder": "已选 {{length}} 个模型",
"add-item": "添加",
"import-item": "导入"
"import-item": "导入",
"sync": "同步上游",
"sync-option": "同步选项",
"sync-site": "上游地址",
"sync-placeholder": "请输入上游 Chat Nio 的 API 地址https://api.chatnio.net",
"sync-result": "发现上游订阅规则数 {{length}} 个,涵盖模型 {{models}} 个, 是否覆盖本站点订阅规则?"
},
"channels": {
"id": "渠道 ID",
@ -585,8 +590,7 @@
"joint-endpoint": "上游地址",
"joint-endpoint-placeholder": "请输入上游 Chat Nio 的 API 地址https://api.chatnio.net",
"upstream-endpoint-placeholder": "请输入上游 OpenAI 地址https://api.openai.com",
"secret": "密钥",
"secret-placeholder": "请输入上游渠道的 API 密钥",
"sync-secret-placeholder": "请输入上游渠道的 API 密钥",
"joint-secret": "API 秘钥",
"joint-secret-placeholder": "请输入上游 Chat Nio 的 API 秘钥",
"sync-failed": "同步失败",

View File

@ -448,7 +448,8 @@
"sync-failed-prompt": "Address could not be requested or model market model is empty\n(Endpoint: {{endpoint}})",
"sync-success": "Sync successfully.",
"sync-success-prompt": "{{length}} models were added from upstream synchronization.",
"upstream-endpoint-placeholder": "Please enter the upstream OpenAI address, e.g. https://api.openai.com"
"upstream-endpoint-placeholder": "Please enter the upstream OpenAI address, e.g. https://api.openai.com",
"sync-secret-placeholder": "Please enter API key for upstream channel"
},
"charge": {
"id": "ID",
@ -624,7 +625,12 @@
"item-models-search-placeholder": "Search Model ID",
"item-models-placeholder": "{{length}} models selected",
"add-item": "add",
"import-item": "Import"
"import-item": "Import",
"sync": "Sync upstream",
"sync-option": "Synchronization Options",
"sync-site": "Upstream address",
"sync-placeholder": "Please enter the API address of the upstream Chat Nio, for example: https://api.chatnio.net",
"sync-result": "The number of upstream subscription rules was found to be {{length}}, covering {{models}} models. Do you want to overwrite the subscription rules on this site?"
},
"model-usage-chart": "Proportion of models used",
"user-type-chart": "Proportion of user types",

View File

@ -448,7 +448,8 @@
"sync-failed-prompt": "住所をリクエストできなかったか、モデルマーケットモデルが空です\nエンドポイント{{ endpoint }}",
"sync-success": "同期成功",
"sync-success-prompt": "{{length}}モデルがアップストリーム同期から追加されました。",
"upstream-endpoint-placeholder": "上流のOpenAIアドレスを入力してください。例 https://api.openai.com"
"upstream-endpoint-placeholder": "上流のOpenAIアドレスを入力してください。例 https://api.openai.com",
"sync-secret-placeholder": "アップストリームチャネルのAPIキーを入力してください"
},
"charge": {
"id": "ID",
@ -624,7 +625,12 @@
"item-models-search-placeholder": "モデルIDを検索",
"item-models-placeholder": "{{length}}モデルが選択されました",
"add-item": "登録",
"import-item": "導入"
"import-item": "導入",
"sync": "アップストリームを同期",
"sync-option": "同期のオプション",
"sync-site": "アップストリームアドレス",
"sync-placeholder": "アップストリームのChat NioのAPIアドレスを入力してください。例 https://api.chatnio.net",
"sync-result": "アップストリームサブスクリプションルールの数は{{length}}で、{{models}}モデルをカバーしています。このサイトのサブスクリプションルールを上書きしますか?"
},
"model-usage-chart": "使用機種の割合",
"user-type-chart": "ユーザータイプの割合",

View File

@ -448,7 +448,8 @@
"sync-failed-prompt": "Адрес не может быть запрошен или модель рынка пуста\n(Конечная точка: {{endpoint}})",
"sync-success": "Успешная синхронизация",
"sync-success-prompt": "{{length}} модели были добавлены из синхронизации восходящего потока.",
"upstream-endpoint-placeholder": "Введите вышестоящий адрес OpenAI, например, https://api.openai.com"
"upstream-endpoint-placeholder": "Введите вышестоящий адрес OpenAI, например, https://api.openai.com",
"sync-secret-placeholder": "Пожалуйста, введите ключ API для восходящего канала"
},
"charge": {
"id": "ID",
@ -624,7 +625,12 @@
"item-models-search-placeholder": "Поиск по идентификатору модели",
"item-models-placeholder": "Выбрано моделей: {{length}}",
"add-item": "Добавить",
"import-item": "Импорт"
"import-item": "Импорт",
"sync": "Синхронизация выше ПО потоку",
"sync-option": "Параметры синхронизации",
"sync-site": "Адрес выше по потоку",
"sync-placeholder": "Введите API-адрес вышестоящего Chat Nio, например: https://api.chatnio.net",
"sync-result": "Было обнаружено, что количество правил подписки выше по потоку составляет {{length}}, охватывая {{models}} моделей. Перезаписать правила подписки на этом сайте?"
},
"model-usage-chart": "Доля используемых моделей",
"user-type-chart": "Доля типов пользователей",

View File

@ -6,15 +6,24 @@ import {
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import { useMemo, useReducer, useState } from "react";
import { getPlanConfig, PlanConfig, setPlanConfig } from "@/admin/api/plan.ts";
import {
getExternalPlanConfig,
getPlanConfig,
PlanConfig,
setPlanConfig,
} from "@/admin/api/plan.ts";
import { useEffectAsync } from "@/utils/hook.ts";
import { Switch } from "@/components/ui/switch.tsx";
import {
Activity,
BookDashed,
ChevronDown,
ChevronUp,
Loader2,
Maximize,
Minimize,
Plus,
RotateCw,
Save,
Trash,
} from "lucide-react";
import {
@ -28,7 +37,6 @@ import { NumberInput } from "@/components/ui/number-input.tsx";
import { Input } from "@/components/ui/input.tsx";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group.tsx";
import { MultiCombobox } from "@/components/ui/multi-combobox.tsx";
import { channelModels } from "@/admin/channel.ts";
import { Button } from "@/components/ui/button.tsx";
import {
DropdownMenu,
@ -40,6 +48,14 @@ import { toastState } from "@/api/common.ts";
import { useToast } from "@/components/ui/use-toast.ts";
import { dispatchSubscriptionData } from "@/store/globals.ts";
import { useDispatch } from "react-redux";
import { cn } from "@/components/ui/lib/utils.ts";
import { useChannelModels } from "@/admin/hook.tsx";
import PopupDialog, {
PopupAlertDialog,
popupTypes,
} from "@/components/PopupDialog.tsx";
import { getUniqueList } from "@/utils/base.ts";
import Icon from "@/components/utils/Icon.tsx";
const planInitialConfig: PlanConfig = {
enabled: false,
@ -356,29 +372,112 @@ function PlanConfig() {
const dispatch = useDispatch();
const { toast } = useToast();
useEffectAsync(async () => {
const { channelModels, update } = useChannelModels();
const [stacked, setStacked] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const [syncOpen, setSyncOpen] = useState<boolean>(false);
const [conf, setConf] = useState<PlanConfig | null>(null);
const confRules = useMemo(
() => (conf ? conf.plans.flatMap((p: Plan) => p.items) : []),
[conf],
);
const confIncluding = useMemo(
() => getUniqueList(confRules.flatMap((i: PlanItem) => i.models)),
[confRules],
);
const refresh = async (ignoreUpdate?: boolean) => {
setLoading(true);
const res = await getPlanConfig();
if (!ignoreUpdate) await update();
formDispatch({ type: "set", payload: res });
setLoading(false);
}, []);
};
const save = async () => {
const res = await setPlanConfig(form);
const save = async (data?: PlanConfig) => {
const res = await setPlanConfig(data ?? form);
toastState(toast, t, res, true);
if (res.status)
dispatchSubscriptionData(dispatch, form.enabled ? form.plans : []);
};
useEffectAsync(async () => await refresh(true), []);
return (
<div className={`plan-config`}>
<PopupDialog
type={popupTypes.Text}
title={t("admin.plan.sync")}
name={t("admin.plan.sync-site")}
placeholder={t("admin.plan.sync-placeholder")}
open={open}
setOpen={setOpen}
defaultValue={"https://api.chatnio.net"}
alert={t("admin.chatnio-format-only")}
onSubmit={async (endpoint): Promise<boolean> => {
const conf = await getExternalPlanConfig(endpoint);
setConf(conf);
setSyncOpen(true);
return true;
}}
/>
<PopupAlertDialog
title={t("admin.plan.sync")}
description={t("admin.plan.sync-result", {
length: confRules.length,
models: confIncluding.length,
})}
open={syncOpen}
setOpen={setSyncOpen}
destructive={true}
onSubmit={async () => {
formDispatch({ type: "set", payload: conf });
conf && (await save(conf));
return true;
}}
/>
<div className={`plan-config-row pb-2`}>
<Button
variant={`outline`}
size={`icon`}
className={`mr-2`}
onClick={() => setStacked(!stacked)}
>
<Icon
icon={stacked ? <Minimize /> : <Maximize />}
className={`h-4 w-4`}
/>
</Button>
<Button variant={`outline`} onClick={() => setOpen(true)}>
<Activity className={`h-4 w-4 mr-2`} />
{t("admin.plan.sync")}
</Button>
<div className={`grow`} />
<Button
variant={`outline`}
className={`mr-2`}
size={`icon`}
onClick={async () => await refresh()}
>
<RotateCw className={cn(`h-4 w-4`, loading && `animate-spin`)} />
</Button>
<Button
variant={`default`}
size={`icon`}
onClick={async () => await save()}
loading={true}
>
<Save className={`h-4 w-4`} />
</Button>
</div>
<div className={`plan-config-row`}>
<p>
{t("admin.plan.enable")}
{loading && (
<Loader2 className={`h-4 w-4 ml-1 inline-block animate-spin`} />
)}
</p>
<p>{t("admin.plan.enable")}</p>
<div className={`grow`} />
<Switch
checked={form.enabled}
@ -398,7 +497,7 @@ function PlanConfig() {
<p className={`select-none flex flex-row items-center mr-2`}>
{t("admin.plan.price")}
<Tips
className={`inline-block translate-y-[2px]`}
className={`inline-block`}
content={t("admin.plan.price-tip")}
/>
</p>
@ -414,7 +513,10 @@ function PlanConfig() {
</div>
<div className={`plan-items-wrapper`}>
{plan.items.map((item: PlanItem, index: number) => (
<div className={`plan-item`} key={index}>
<div
className={cn("plan-item", stacked && "stacked")}
key={index}
>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-id`)}
@ -435,26 +537,29 @@ function PlanConfig() {
placeholder={t(`admin.plan.item-id-placeholder`)}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-name`)}
<Tips content={t("admin.plan.item-name-placeholder")} />
</p>
<Input
value={item.name}
onChange={(e) => {
formDispatch({
type: "set-item-name",
payload: {
level: plan.level,
name: e.target.value,
index,
},
});
}}
placeholder={t(`admin.plan.item-name-placeholder`)}
/>
</div>
{!stacked && (
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-name`)}
<Tips content={t("admin.plan.item-name-placeholder")} />
</p>
<Input
value={item.name}
onChange={(e) => {
formDispatch({
type: "set-item-name",
payload: {
level: plan.level,
name: e.target.value,
index,
},
});
}}
placeholder={t(`admin.plan.item-name-placeholder`)}
/>
</div>
)}
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-value`)}
@ -472,50 +577,69 @@ function PlanConfig() {
}}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-models`)}
<Tips content={t("admin.plan.item-models-tip")} />
</p>
<MultiCombobox
align={`start`}
value={item.models}
onChange={(value: string[]) => {
formDispatch({
type: "set-item-models",
payload: { level: plan.level, models: value, index },
});
}}
placeholder={t(`admin.plan.item-models-placeholder`, {
length: item.models.length,
})}
searchPlaceholder={t(
`admin.plan.item-models-search-placeholder`,
)}
list={channelModels}
className={`w-full max-w-full`}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-icon`)}
<Tips content={t("admin.plan.item-icon-tip")} />
</p>
<div className={`grow`} />
<ItemIconEditor
value={item.icon}
onValueChange={(value: string) => {
formDispatch({
type: "set-item-icon",
payload: { level: plan.level, icon: value, index },
});
}}
/>
</div>
<div className={`flex flex-row flex-wrap gap-1`}>
<div className={`grow`} />
{!stacked && (
<>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-models`)}
<Tips content={t("admin.plan.item-models-tip")} />
</p>
<MultiCombobox
align={`start`}
value={item.models}
onChange={(value: string[]) => {
formDispatch({
type: "set-item-models",
payload: {
level: plan.level,
models: value,
index,
},
});
}}
placeholder={t(`admin.plan.item-models-placeholder`, {
length: item.models.length,
})}
searchPlaceholder={t(
`admin.plan.item-models-search-placeholder`,
)}
list={channelModels}
className={`w-full max-w-full`}
/>
</div>
<div className={`plan-editor-row`}>
<p className={`plan-editor-label mr-2`}>
{t(`admin.plan.item-icon`)}
<Tips content={t("admin.plan.item-icon-tip")} />
</p>
<div className={`grow`} />
<ItemIconEditor
value={item.icon}
onValueChange={(value: string) => {
formDispatch({
type: "set-item-icon",
payload: {
level: plan.level,
icon: value,
index,
},
});
}}
/>
</div>
</>
)}
<div
className={cn(
`flex flex-row gap-1`,
!stacked && "flex-wrap",
)}
>
{!stacked && <div className={`grow`} />}
<Button
variant={`outline`}
size={stacked ? "icon" : "default"}
onClick={() => {
formDispatch({
type: "upward-item",
@ -524,11 +648,14 @@ function PlanConfig() {
}}
disabled={index === 0}
>
<ChevronUp className={`h-4 w-4 mr-1`} />
{t("upward")}
<ChevronUp
className={cn("h-4 w-4", !stacked && "mr-1")}
/>
{!stacked && t("upward")}
</Button>
<Button
variant={`outline`}
size={stacked ? "icon" : "default"}
onClick={() => {
formDispatch({
type: "downward-item",
@ -537,11 +664,14 @@ function PlanConfig() {
}}
disabled={index === plan.items.length - 1}
>
<ChevronDown className={`h-4 w-4 mr-1`} />
{t("downward")}
<ChevronDown
className={cn("h-4 w-4", !stacked && "mr-1")}
/>
{!stacked && t("downward")}
</Button>
<Button
variant={`default`}
size={stacked ? "icon" : "default"}
onClick={() => {
formDispatch({
type: "remove-item",
@ -549,8 +679,8 @@ function PlanConfig() {
});
}}
>
<Trash className={`h-4 w-4 mr-1`} />
{t("remove")}
<Trash className={cn("h-4 w-4", !stacked && "mr-1")} />
{!stacked && t("remove")}
</Button>
</div>
</div>
@ -580,7 +710,7 @@ function PlanConfig() {
))}
<div className={`flex flex-row flex-wrap gap-1`}>
<div className={`grow`} />
<Button loading={true} onClick={save}>
<Button loading={true} onClick={async () => await save()}>
{t("save")}
</Button>
</div>