mirror of
https://github.com/coaidev/coai.git
synced 2025-05-19 21:10:18 +09:00
feat: support plan config sync upstream and edit folding support
This commit is contained in:
parent
7432ae6714
commit
7ba52d62d3
@ -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
35
app/src/admin/hook.tsx
Normal 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,
|
||||
};
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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`}>
|
||||
|
@ -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>
|
||||
|
@ -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 && (
|
||||
|
@ -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": "同步失败",
|
||||
|
@ -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",
|
||||
|
@ -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": "ユーザータイプの割合",
|
||||
|
@ -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": "Доля типов пользователей",
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user