coai/app/src/routes/admin/Subscription.tsx

738 lines
23 KiB
TypeScript

import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import { useMemo, useReducer, useState } from "react";
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,
Maximize,
Minimize,
Plus,
RotateCw,
Save,
Trash,
} from "lucide-react";
import {
getPlanName,
SubscriptionIcon,
subscriptionIconsList,
} from "@/conf/subscription.tsx";
import { Plan, PlanItem } from "@/api/types.tsx";
import Tips from "@/components/Tips.tsx";
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 { Button } from "@/components/ui/button.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
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,
plans: [],
};
function reducer(state: PlanConfig, action: Record<string, any>): PlanConfig {
switch (action.type) {
case "set":
return action.payload;
case "set-enabled":
return {
...state,
enabled: action.payload,
};
case "set-price":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
price: action.payload.price,
};
}
return plan;
}),
};
case "set-item-id":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
id: action.payload.id,
};
}
return item;
}),
};
}
return plan;
}),
};
case "set-item-name":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
name: action.payload.name,
};
}
return item;
}),
};
}
return plan;
}),
};
case "set-item-value":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
value: action.payload.value,
};
}
return item;
}),
};
}
return plan;
}),
};
case "set-item-icon":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
icon: action.payload.icon,
};
}
return item;
}),
};
}
return plan;
}),
};
case "add-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: [
...plan.items,
{
id: "",
name: "",
value: 0,
icon: subscriptionIconsList[0],
models: [],
},
],
};
}
return plan;
}),
};
case "set-item-models":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.map((item: PlanItem, index: number) => {
if (index === action.payload.index) {
return {
...item,
models: action.payload.models,
};
}
return item;
}),
};
}
return plan;
}),
};
case "remove-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
return {
...plan,
items: plan.items.filter(
(_: PlanItem, index: number) => index !== action.payload.index,
),
};
}
return plan;
}),
};
case "upward-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
const items = plan.items;
const index = action.payload.index;
if (index > 0) {
const tmp = items[index];
items[index] = items[index - 1];
items[index - 1] = tmp;
}
return {
...plan,
items,
};
}
return plan;
}),
};
case "downward-item":
return {
...state,
plans: state.plans.map((plan: Plan) => {
if (plan.level === action.payload.level) {
const items = plan.items;
const index = action.payload.index;
if (index < items.length - 1) {
const tmp = items[index];
items[index] = items[index + 1];
items[index + 1] = tmp;
}
return {
...plan,
items,
};
}
return plan;
}),
};
case "import-item":
const { level, id, target } = action.payload;
const plan = state.plans.find((p: Plan) => p.level === level);
const item = plan?.items.find((i: PlanItem) => i.id === id);
if (!plan || !item) return state;
return {
...state,
plans: state.plans.map((p: Plan) => {
if (p.level === target) {
const items = p.items;
items.push(item);
return {
...p,
items,
};
}
return p;
}),
};
default:
throw new Error();
}
}
type ItemIconEditorProps = {
value: string;
onValueChange: (value: string) => void;
};
function ItemIconEditor({ value, onValueChange }: ItemIconEditorProps) {
return (
<ToggleGroup
variant={`outline`}
type={`single`}
value={value}
onValueChange={onValueChange}
className={`flex-wrap`}
>
{subscriptionIconsList.map((key, index) => (
<ToggleGroupItem value={key} key={index}>
<SubscriptionIcon type={key} className={`h-4 w-4`} />
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
type ImportActionProps = {
plans: Plan[];
level: number;
dispatch: (action: Record<string, any>) => void;
};
type ImportActionItem = {
item: PlanItem;
level: number;
};
function ImportAction({ plans, level, dispatch }: ImportActionProps) {
const { t } = useTranslation();
const usableItems = useMemo((): ImportActionItem[] => {
const raw = plans.filter((p: Plan) => p.level !== level);
return raw
.map((p: Plan) =>
p.items.map((item: PlanItem) => ({ level: p.level, item })),
)
.flat();
}, [plans, level]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={`outline`}>
<BookDashed className={`h-4 w-4 mr-1`} />
{t("admin.plan.import-item")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{usableItems.map(
({ level: from, item }: ImportActionItem, index: number) => (
<DropdownMenuItem
key={index}
onClick={() => {
dispatch({
type: "import-item",
payload: { level: from, id: item.id, target: level },
});
}}
>
{t(`sub.${getPlanName(from)}`)} - {item.name} ({item.id})
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function PlanConfig() {
const { t } = useTranslation();
const [form, formDispatch] = useReducer(reducer, planInitialConfig);
const [loading, setLoading] = useState<boolean>(false);
const dispatch = useDispatch();
const { toast } = useToast();
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 (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`} onClick={() => setOpen(true)}>
<Activity className={`h-4 w-4 mr-2`} />
{t("admin.plan.sync")}
</Button>
<div className={`grow`} />
<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`}
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")}</p>
<div className={`grow`} />
<Switch
checked={form.enabled}
onCheckedChange={(checked: boolean) =>
formDispatch({ type: "set-enabled", payload: checked })
}
/>
</div>
{form.enabled &&
form.plans.map((plan: Plan, index: number) => (
<div className={`plan-config-card`} key={index}>
<p className={`plan-config-title`}>
{t(`sub.${getPlanName(plan.level)}`)}
</p>
<div className={`plan-editor-row`}>
<p className={`select-none flex flex-row items-center mr-2`}>
{t("admin.plan.price")}
<Tips
className={`inline-block`}
content={t("admin.plan.price-tip")}
/>
</p>
<NumberInput
value={plan.price}
onValueChange={(value: number) => {
formDispatch({
type: "set-price",
payload: { level: plan.level, price: value },
});
}}
/>
</div>
<div className={`plan-items-wrapper`}>
{plan.items.map((item: PlanItem, index: number) => (
<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`)}
<Tips content={t("admin.plan.item-id-placeholder")} />
</p>
<Input
value={item.id}
onChange={(e) => {
formDispatch({
type: "set-item-id",
payload: {
level: plan.level,
id: e.target.value,
index,
},
});
}}
placeholder={t(`admin.plan.item-id-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`)}
<Tips content={t("admin.plan.item-value-tip")} />
</p>
<NumberInput
value={item.value}
min={-1}
acceptNegative={true}
onValueChange={(value: number) => {
formDispatch({
type: "set-item-value",
payload: { level: plan.level, value, index },
});
}}
/>
</div>
{!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",
payload: { level: plan.level, index },
});
}}
disabled={index === 0}
>
<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",
payload: { level: plan.level, index },
});
}}
disabled={index === plan.items.length - 1}
>
<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",
payload: { level: plan.level, index },
});
}}
>
<Trash className={cn("h-4 w-4", !stacked && "mr-1")} />
{!stacked && t("remove")}
</Button>
</div>
</div>
))}
</div>
<div className={`plan-items-action`}>
<div className={`grow`} />
<ImportAction
plans={form.plans}
level={plan.level}
dispatch={formDispatch}
/>
<Button
variant={`default`}
onClick={() => {
formDispatch({
type: "add-item",
payload: { level: plan.level },
});
}}
>
<Plus className={`h-4 w-4 mr-1`} />
{t("admin.plan.add-item")}
</Button>
</div>
</div>
))}
<div className={`flex flex-row flex-wrap gap-1`}>
<div className={`grow`} />
<Button loading={true} onClick={async () => await save()}>
{t("save")}
</Button>
</div>
</div>
);
}
function Subscription() {
const { t } = useTranslation();
return (
<div className={`admin-subscription`}>
<Card className={`admin-card sub-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.subscription")}</CardTitle>
</CardHeader>
<CardContent>
<PlanConfig />
</CardContent>
</Card>
</div>
);
}
export default Subscription;