diff --git a/app/src/admin/api/plan.ts b/app/src/admin/api/plan.ts index 3eb026e..14496e4 100644 --- a/app/src/admin/api/plan.ts +++ b/app/src/admin/api/plan.ts @@ -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 { } } +export async function getExternalPlanConfig( + endpoint: string, +): Promise { + const response = await getApiPlans({ endpoint }); + return { enabled: response.length > 0, plans: response }; +} + export async function setPlanConfig( config: PlanConfig, ): Promise { diff --git a/app/src/admin/hook.tsx b/app/src/admin/hook.tsx new file mode 100644 index 0000000..557ca60 --- /dev/null +++ b/app/src/admin/hook.tsx @@ -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([]); + + 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, + }; +}; diff --git a/app/src/assets/admin/subscription.less b/app/src/assets/admin/subscription.less index 225980f..a26eaa2 100644 --- a/app/src/assets/admin/subscription.less +++ b/app/src/assets/admin/subscription.less @@ -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; } diff --git a/app/src/components/PopupDialog.tsx b/app/src/components/PopupDialog.tsx index d1eb885..077db62 100644 --- a/app/src/components/PopupDialog.tsx +++ b/app/src/components/PopupDialog.tsx @@ -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; +}; + +export function PopupAlertDialog({ + title, + description, + open, + setOpen, + cancelLabel, + confirmLabel, + destructive, + disabled, + onSubmit, +}: PopupAlertDialogProps) { + const { t } = useTranslation(); + + return ( + + + + {title} + {description && ( + {description} + )} + + + {cancelLabel || t("cancel")} + + + + + ); +} + export default PopupDialog; diff --git a/app/src/components/admin/ChargeWidget.tsx b/app/src/components/admin/ChargeWidget.tsx index 44056f5..c46b6da 100644 --- a/app/src/components/admin/ChargeWidget.tsx +++ b/app/src/components/admin/ChargeWidget.tsx @@ -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([]); + 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 (
diff --git a/app/src/components/admin/assemblies/ChannelTable.tsx b/app/src/components/admin/assemblies/ChannelTable.tsx index 26ed5a8..00d0b68 100644 --- a/app/src/components/admin/assemblies/ChannelTable.tsx +++ b/app/src/components/admin/assemblies/ChannelTable.tsx @@ -131,7 +131,7 @@ function SyncDialog({ dispatch, open, setOpen }: SyncDialogProps) { setSecret(e.target.value)} - placeholder={t("admin.channels.secret-placeholder")} + placeholder={t("admin.channels.sync-secret-placeholder")} />
diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx index 64681b3..7ec0147 100644 --- a/app/src/components/ui/button.tsx +++ b/app/src/components/ui/button.tsx @@ -88,6 +88,12 @@ const Button = React.forwardRef( const child = useMemo(() => { if (asChild) return children; + if (size === "icon" || size === "icon-sm") { + if (loading && working) { + return ; + } + } + return ( <> {loading && working && ( diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index f11c86c..0d36727 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -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": "同步失败", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index e8eace1..1eec052 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -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", diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index 5c6e516..7fe7fe5 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -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": "ユーザータイプの割合", diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index 777610e..06bc737 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -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": "Доля типов пользователей", diff --git a/app/src/routes/admin/Subscription.tsx b/app/src/routes/admin/Subscription.tsx index 193c1e3..aa1ce2b 100644 --- a/app/src/routes/admin/Subscription.tsx +++ b/app/src/routes/admin/Subscription.tsx @@ -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(false); + + const [open, setOpen] = useState(false); + const [syncOpen, setSyncOpen] = useState(false); + const [conf, setConf] = useState(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 (
+ => { + const conf = await getExternalPlanConfig(endpoint); + setConf(conf); + setSyncOpen(true); + + return true; + }} + /> + { + formDispatch({ type: "set", payload: conf }); + conf && (await save(conf)); + + return true; + }} + /> +
+ + +
+ + +
+
-

- {t("admin.plan.enable")} - {loading && ( - - )} -

+

{t("admin.plan.enable")}

{t("admin.plan.price")}

@@ -414,7 +513,10 @@ function PlanConfig() {
{plan.items.map((item: PlanItem, index: number) => ( -
+

{t(`admin.plan.item-id`)} @@ -435,26 +537,29 @@ function PlanConfig() { placeholder={t(`admin.plan.item-id-placeholder`)} />

-
-

- {t(`admin.plan.item-name`)} - -

- { - formDispatch({ - type: "set-item-name", - payload: { - level: plan.level, - name: e.target.value, - index, - }, - }); - }} - placeholder={t(`admin.plan.item-name-placeholder`)} - /> -
+ {!stacked && ( +
+

+ {t(`admin.plan.item-name`)} + +

+ { + formDispatch({ + type: "set-item-name", + payload: { + level: plan.level, + name: e.target.value, + index, + }, + }); + }} + placeholder={t(`admin.plan.item-name-placeholder`)} + /> +
+ )} +

{t(`admin.plan.item-value`)} @@ -472,50 +577,69 @@ function PlanConfig() { }} />

-
-

- {t(`admin.plan.item-models`)} - -

- { - 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`} - /> -
-
-

- {t(`admin.plan.item-icon`)} - -

-
- { - formDispatch({ - type: "set-item-icon", - payload: { level: plan.level, icon: value, index }, - }); - }} - /> -
-
-
+ + {!stacked && ( + <> +
+

+ {t(`admin.plan.item-models`)} + +

+ { + 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`} + /> +
+
+

+ {t(`admin.plan.item-icon`)} + +

+
+ { + formDispatch({ + type: "set-item-icon", + payload: { + level: plan.level, + icon: value, + index, + }, + }); + }} + /> +
+ + )} +
+ {!stacked &&
}
@@ -580,7 +710,7 @@ function PlanConfig() { ))}
-