mirror of
https://github.com/coaidev/coai.git
synced 2025-05-20 05:20:15 +09:00
feat: support custom subscription config (#24)
This commit is contained in:
parent
aacc6b9c12
commit
ed74b6b975
@ -29,6 +29,7 @@
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.4",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@tanem/react-nprogress": "^5.0.51",
|
||||
|
30
app/pnpm-lock.yaml
generated
30
app/pnpm-lock.yaml
generated
@ -56,6 +56,9 @@ dependencies:
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.0.6
|
||||
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
|
||||
@ -1588,6 +1591,33 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.2
|
||||
'@radix-ui/primitive': 1.0.1
|
||||
'@radix-ui/react-context': 1.0.1(@types/react@18.2.33)(react@18.2.0)
|
||||
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.33)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.33)(react@18.2.0)
|
||||
'@types/react': 18.2.33
|
||||
'@types/react-dom': 18.2.14
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==}
|
||||
peerDependencies:
|
||||
|
@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "chatnio",
|
||||
"version": "3.8.6"
|
||||
"version": "3.9.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
36
app/src/admin/api/plan.ts
Normal file
36
app/src/admin/api/plan.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Plan } from "@/api/types";
|
||||
import axios from "axios";
|
||||
import { CommonResponse } from "@/admin/utils.ts";
|
||||
import { getErrorMessage } from "@/utils/base.ts";
|
||||
|
||||
export type PlanConfig = {
|
||||
enabled: boolean;
|
||||
plans: Plan[];
|
||||
};
|
||||
|
||||
export async function getPlanConfig(): Promise<PlanConfig> {
|
||||
try {
|
||||
const response = await axios.get("/admin/plan/view");
|
||||
const conf = response.data as PlanConfig;
|
||||
conf.plans = (conf.plans || []).filter((item) => item.level > 0);
|
||||
if (conf.plans.length === 0)
|
||||
conf.plans = [1, 2, 3].map(
|
||||
(level) => ({ level, price: 0, items: [] }) as Plan,
|
||||
);
|
||||
return conf;
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return { enabled: false, plans: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setPlanConfig(
|
||||
config: PlanConfig,
|
||||
): Promise<CommonResponse> {
|
||||
try {
|
||||
const response = await axios.post(`/admin/plan/update`, config);
|
||||
return response.data as CommonResponse;
|
||||
} catch (e) {
|
||||
return { status: false, error: getErrorMessage(e) };
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ import { getMemory } from "@/utils/memory.ts";
|
||||
import { getErrorMessage } from "@/utils/base.ts";
|
||||
|
||||
export const endpoint = `${websocketEndpoint}/chat`;
|
||||
export const maxRetry = 5;
|
||||
export const maxRetry = 30; // 15s max websocket retry
|
||||
|
||||
export type StreamMessage = {
|
||||
conversation?: number;
|
||||
|
@ -11,3 +11,127 @@
|
||||
min-height: 20vh;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
& > * {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-config-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plan-config-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(var(--border));
|
||||
|
||||
.plan-config-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:before {
|
||||
display: inline-block;
|
||||
content: '';
|
||||
margin-right: 0.5rem;
|
||||
height: 1.25rem;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: hsl(var(--text-secondary));
|
||||
transition: .25s;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-items-action {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.plan-items-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
margin-top: 1rem;
|
||||
|
||||
.plan-item {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(var(--border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.plan-editor-row > p {
|
||||
min-width: 4.25rem;
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plan-editor-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.plan-editor-label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
svg {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
94
app/src/components/ui/multi-combobox.tsx
Normal file
94
app/src/components/ui/multi-combobox.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
|
||||
import { cn } from "@/components/ui/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MultiComboBoxProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
list: string[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
align?: "start" | "end" | "center" | undefined;
|
||||
};
|
||||
|
||||
export function MultiCombobox({
|
||||
value,
|
||||
onChange,
|
||||
list,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
defaultOpen,
|
||||
className,
|
||||
align,
|
||||
}: MultiComboBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(defaultOpen ?? false);
|
||||
const valueList = React.useMemo((): string[] => {
|
||||
// list set
|
||||
const set = new Set(list);
|
||||
return [...set];
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-[320px] max-w-[60vw] justify-between", className)}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
{placeholder ?? `${value.length} Items Selected`}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] max-w-[60vw] p-0" align={align}>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandEmpty>{t("admin.empty")}</CommandEmpty>
|
||||
<CommandList>
|
||||
{valueList.map((key) => (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={(current) => {
|
||||
if (value.includes(current)) {
|
||||
onChange(value.filter((item) => item !== current));
|
||||
} else {
|
||||
onChange([...value, current]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value.includes(key) ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{key}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
@ -37,8 +37,8 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
return v.match(exp)?.join("") || "";
|
||||
}
|
||||
|
||||
// replace -0124.5 to -124.5, 0043 to 43
|
||||
const exp = /^[-+]?0+(?=[1-9])|(?<=\.)0+(?=[1-9])|(?<=\.)0+$/g;
|
||||
// replace -0124.5 to -124.5, 0043 to 43, 2.000 to 2.000
|
||||
const exp = /^[-+]?0+(?=[0-9]+(\.[0-9]+)?$)/;
|
||||
v = v.replace(exp, "");
|
||||
|
||||
const raw = getNumber(v, props.acceptNegative);
|
||||
|
59
app/src/components/ui/toggle-group.tsx
Normal file
59
app/src/components/ui/toggle-group.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/components/ui/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
@ -9,7 +9,7 @@ import { syncSiteInfo } from "@/admin/api/info.ts";
|
||||
import { getOfflineModels, loadPreferenceModels } from "@/conf/storage.ts";
|
||||
import { setAxiosConfig } from "@/conf/api.ts";
|
||||
|
||||
export const version = "3.8.6"; // version of the current build
|
||||
export const version = "3.9.0"; // version of the current build
|
||||
export const dev: boolean = getDev(); // is in development mode (for debugging, in localhost origin)
|
||||
export const deploy: boolean = true; // is production environment (for api endpoint)
|
||||
export const tokenField = getTokenField(deploy); // token field name for storing token
|
||||
|
@ -5,6 +5,9 @@ import {
|
||||
ImagePlus,
|
||||
Video,
|
||||
AudioLines,
|
||||
Container,
|
||||
Archive,
|
||||
Flame,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Plan, Plans } from "@/api/types.ts";
|
||||
@ -17,8 +20,13 @@ export const subscriptionIcons: Record<string, React.ReactElement> = {
|
||||
booktext: <BookText />,
|
||||
video: <Video />,
|
||||
audio: <AudioLines />,
|
||||
flame: <Flame />,
|
||||
archive: <Archive />,
|
||||
container: <Container />,
|
||||
};
|
||||
|
||||
export const subscriptionIconsList: string[] = Object.keys(subscriptionIcons);
|
||||
|
||||
export const subscriptionType: Record<number, string> = {
|
||||
1: "basic",
|
||||
2: "standard",
|
||||
|
@ -38,6 +38,11 @@
|
||||
"download-fatal-log": "下载错误日志",
|
||||
"fatal-tips": "请您先检查您的网络,浏览器兼容性,尝试清除浏览器缓存并刷新页面。如果问题仍然存在,请将日志提供给开发者以便我们排查问题。",
|
||||
"request-error": "请求失败,原因:{{reason}}",
|
||||
"delete": "删除",
|
||||
"remove": "移除",
|
||||
"upward": "上移",
|
||||
"downward": "下移",
|
||||
"save": "保存",
|
||||
"auth": {
|
||||
"username": "用户名",
|
||||
"username-placeholder": "请输入用户名",
|
||||
@ -422,6 +427,25 @@
|
||||
"used": "已用个数",
|
||||
"total": "总个数"
|
||||
},
|
||||
"plan": {
|
||||
"enable": "启用订阅",
|
||||
"price": "价格",
|
||||
"price-tip": "一月订阅价格 (单位:元)",
|
||||
"item-id": "ID",
|
||||
"item-id-placeholder": "请输入实体 ID (Item ID 不能多次使用,如:gpt-4)",
|
||||
"item-name": "名称",
|
||||
"item-name-placeholder": "请输入实体名称 (Item Name 用于显示在订阅列表中的实体名,如:GPT-4)",
|
||||
"item-value": "配额",
|
||||
"item-value-tip": "每月配额 (单位:次)",
|
||||
"item-icon": "图标",
|
||||
"item-icon-tip": "实体图标 (Item Icon 用于显示在订阅列表中的图标)",
|
||||
"item-models": "模型",
|
||||
"item-models-tip": "实体涵盖的模型 (Item Models 用于显示在订阅列表中的模型)",
|
||||
"item-models-search-placeholder": "搜索模型 ID",
|
||||
"item-models-placeholder": "已选 {{length}} 个模型",
|
||||
"add-item": "添加",
|
||||
"import-item": "导入"
|
||||
},
|
||||
"channels": {
|
||||
"id": "渠道 ID",
|
||||
"name": "名称",
|
||||
|
@ -473,6 +473,25 @@
|
||||
"title": "service log",
|
||||
"console": "Console",
|
||||
"consoleLength": "Number of log entries"
|
||||
},
|
||||
"plan": {
|
||||
"enable": "Enable subscriptions",
|
||||
"price": "Price",
|
||||
"price-tip": "January subscription price (unit: yuan)",
|
||||
"item-id": "ID",
|
||||
"item-id-placeholder": "Please enter Entity ID (Item ID cannot be used more than once, ex: gpt-4)",
|
||||
"item-name": "Name",
|
||||
"item-name-placeholder": "Please enter the entity name (Item Name is used to display the entity name in the subscription list, e.g. GPT-4)",
|
||||
"item-value": "quota",
|
||||
"item-value-tip": "Monthly quota (unit: times)",
|
||||
"item-icon": "Icon",
|
||||
"item-icon-tip": "Entity icons (icons used by Item Icons to appear in the subscription list)",
|
||||
"item-models": "Model",
|
||||
"item-models-tip": "The models covered by the entity (Item Models are used to display the models in the subscription list)",
|
||||
"item-models-search-placeholder": "Search Model ID",
|
||||
"item-models-placeholder": "{{length}} models selected",
|
||||
"add-item": "add",
|
||||
"import-item": "Import"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
@ -519,5 +538,10 @@
|
||||
},
|
||||
"reset": "Reset",
|
||||
"request-error": "Request failed for {{reason}}",
|
||||
"update": "Updated"
|
||||
"update": "Updated",
|
||||
"delete": "Delete",
|
||||
"remove": "remove",
|
||||
"upward": "Top",
|
||||
"downward": "Move down",
|
||||
"save": "Save"
|
||||
}
|
@ -473,6 +473,25 @@
|
||||
"title": "サービスログ",
|
||||
"console": "コンソール",
|
||||
"consoleLength": "ログエントリの数"
|
||||
},
|
||||
"plan": {
|
||||
"enable": "サブスクリプションを有効にする",
|
||||
"price": "価格",
|
||||
"price-tip": "1月のサブスクリプション価格(単位:元)",
|
||||
"item-id": "ID",
|
||||
"item-id-placeholder": "エンティティIDを入力してください(アイテムIDは複数回使用することはできません。例: gpt -4 )",
|
||||
"item-name": "名称",
|
||||
"item-name-placeholder": "エンティティ名を入力してください(アイテム名はサブスクリプションリストにエンティティ名を表示するために使用されます。例: GPT -4 )",
|
||||
"item-value": "クォータ",
|
||||
"item-value-tip": "月間クォータ(単位:回)",
|
||||
"item-icon": "を使って「",
|
||||
"item-icon-tip": "エンティティアイコン(サブスクリプションリストに表示されるアイテムアイコンで使用されるアイコン)",
|
||||
"item-models": "モデル",
|
||||
"item-models-tip": "エンティティがカバーするモデル(アイテムモデルは、サブスクリプションリストにモデルを表示するために使用されます)",
|
||||
"item-models-search-placeholder": "モデルIDを検索",
|
||||
"item-models-placeholder": "{{length}}モデルが選択されました",
|
||||
"add-item": "登録",
|
||||
"import-item": "導入"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
@ -519,5 +538,10 @@
|
||||
},
|
||||
"reset": "リセット",
|
||||
"request-error": "{{reason}}のためにリクエストできませんでした",
|
||||
"update": "更新"
|
||||
"update": "更新",
|
||||
"delete": "削除",
|
||||
"remove": "追放",
|
||||
"upward": "上へ移動",
|
||||
"downward": "下へ移動",
|
||||
"save": "保存"
|
||||
}
|
@ -473,6 +473,25 @@
|
||||
"title": "Журнал обслуживания",
|
||||
"console": "Консоль",
|
||||
"consoleLength": "Количество записей в журнале"
|
||||
},
|
||||
"plan": {
|
||||
"enable": "Включить подписку",
|
||||
"price": "Цена",
|
||||
"price-tip": "Цена подписки за январь (единица: юань)",
|
||||
"item-id": "Айди",
|
||||
"item-id-placeholder": "Введите идентификатор организации (идентификатор позиции не может использоваться более одного раза, например: gpt-4)",
|
||||
"item-name": "",
|
||||
"item-name-placeholder": "Пожалуйста, введите название объекта (название объекта используется для отображения названия объекта в списке подписки, например GPT-4)",
|
||||
"item-value": "Всего",
|
||||
"item-value-tip": "Ежемесячная квота (единица измерения: раз)",
|
||||
"item-icon": "Иконка",
|
||||
"item-icon-tip": "Значки сущностей (значки, используемые значками элементов для отображения в списке подписки)",
|
||||
"item-models": "Модели",
|
||||
"item-models-tip": "Модели, охватываемые сущностью (модели элементов используются для отображения моделей в списке подписки)",
|
||||
"item-models-search-placeholder": "Поиск по идентификатору модели",
|
||||
"item-models-placeholder": "Выбрано моделей: {{length}}",
|
||||
"add-item": "Добавить",
|
||||
"import-item": "Импорт"
|
||||
}
|
||||
},
|
||||
"mask": {
|
||||
@ -519,5 +538,10 @@
|
||||
},
|
||||
"reset": "сброс",
|
||||
"request-error": "Запрос не выполнен по {{reason}}",
|
||||
"update": "Обновить"
|
||||
"update": "Обновить",
|
||||
"delete": "удалять",
|
||||
"remove": "Убрать",
|
||||
"upward": "Выше",
|
||||
"downward": "Ниже",
|
||||
"save": "Сохранить"
|
||||
}
|
@ -119,7 +119,7 @@ function Logger() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`logger`}>
|
||||
<Card className={`logger-card`}>
|
||||
<Card className={`admin-card logger-card`}>
|
||||
<CardHeader className={`select-none`}>
|
||||
<CardTitle>{t("admin.logger.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
|
@ -5,16 +5,598 @@ import {
|
||||
CardTitle,
|
||||
} 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 { useEffectAsync } from "@/utils/hook.ts";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import {
|
||||
BookDashed,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getPlanName,
|
||||
SubscriptionIcon,
|
||||
subscriptionIconsList,
|
||||
} from "@/conf/subscription.tsx";
|
||||
import { Plan, PlanItem } from "@/api/types.ts";
|
||||
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 { channelModels } from "@/admin/channel.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import { toastState } from "@/admin/utils.ts";
|
||||
import { useToast } from "@/components/ui/use-toast.ts";
|
||||
import { dispatchSubscriptionData } from "@/store/globals.ts";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
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();
|
||||
|
||||
useEffectAsync(async () => {
|
||||
setLoading(true);
|
||||
const res = await getPlanConfig();
|
||||
formDispatch({ type: "set", payload: res });
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const res = await setPlanConfig(form);
|
||||
toastState(toast, t, res, true);
|
||||
if (res.status)
|
||||
dispatchSubscriptionData(dispatch, form.enabled ? form.plans : []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`plan-config`}>
|
||||
<div className={`plan-config-row`}>
|
||||
<p>
|
||||
{t("admin.plan.enable")}
|
||||
{loading && (
|
||||
<Loader2 className={`h-4 w-4 ml-1 inline-block animate-spin`} />
|
||||
)}
|
||||
</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 translate-y-[2px]`}
|
||||
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={`plan-item`} 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>
|
||||
<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}
|
||||
onValueChange={(value: number) => {
|
||||
formDispatch({
|
||||
type: "set-item-value",
|
||||
payload: { level: plan.level, value, index },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</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`} />
|
||||
<Button
|
||||
variant={`outline`}
|
||||
onClick={() => {
|
||||
formDispatch({
|
||||
type: "upward-item",
|
||||
payload: { level: plan.level, index },
|
||||
});
|
||||
}}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className={`h-4 w-4 mr-1`} />
|
||||
{t("upward")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={`outline`}
|
||||
onClick={() => {
|
||||
formDispatch({
|
||||
type: "downward-item",
|
||||
payload: { level: plan.level, index },
|
||||
});
|
||||
}}
|
||||
disabled={index === plan.items.length - 1}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 mr-1`} />
|
||||
{t("downward")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={`default`}
|
||||
onClick={() => {
|
||||
formDispatch({
|
||||
type: "remove-item",
|
||||
payload: { level: plan.level, index },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash className={`h-4 w-4 mr-1`} />
|
||||
{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={save}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Subscription() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`admin-subscription`}>
|
||||
<Card className={`sub-card`}>
|
||||
<Card className={`admin-card sub-card`}>
|
||||
<CardHeader className={`select-none`}>
|
||||
<CardTitle>{t("admin.subscription")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>in development</CardContent>
|
||||
<CardContent>
|
||||
<PlanConfig />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"chat/channel"
|
||||
"chat/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
@ -346,6 +347,18 @@ func SubscriptionAPI(c *gin.Context) {
|
||||
|
||||
db := utils.GetDBFromContext(c)
|
||||
cache := utils.GetCacheFromContext(c)
|
||||
|
||||
if disableSubscription() {
|
||||
c.JSON(200, gin.H{
|
||||
"status": true,
|
||||
"level": 0,
|
||||
"is_subscribed": false,
|
||||
"enterprise": false,
|
||||
"expired": 0,
|
||||
"usage": channel.UsageMap{},
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": true,
|
||||
"level": user.GetSubscriptionLevel(db),
|
||||
@ -381,7 +394,7 @@ func SubscribeAPI(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if BuySubscription(db, cache, user, form.Level, form.Month) {
|
||||
if err := BuySubscription(db, cache, user, form.Level, form.Month); err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"status": true,
|
||||
"error": "success",
|
||||
@ -389,7 +402,7 @@ func SubscribeAPI(c *gin.Context) {
|
||||
} else {
|
||||
c.JSON(200, gin.H{
|
||||
"status": false,
|
||||
"error": "not enough money",
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +73,10 @@ func (u *User) GetID(db *sql.DB) int64 {
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func (u *User) HitID() int64 {
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func (u *User) GetEmail(db *sql.DB) string {
|
||||
if len(u.Email) > 0 {
|
||||
return u.Email
|
||||
|
@ -1,14 +1,20 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"chat/channel"
|
||||
"chat/utils"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
func disableSubscription() bool {
|
||||
return !channel.PlanInstance.IsEnabled()
|
||||
}
|
||||
|
||||
func (u *User) GetSubscription(db *sql.DB) (time.Time, int) {
|
||||
if u.Subscription != nil && u.Subscription.Unix() > 0 {
|
||||
return *u.Subscription, u.Level
|
||||
@ -31,8 +37,8 @@ func (u *User) GetSubscriptionLevel(db *sql.DB) int {
|
||||
return level
|
||||
}
|
||||
|
||||
func (u *User) GetPlan(db *sql.DB) Plan {
|
||||
return GetLevel(u.GetSubscriptionLevel(db))
|
||||
func (u *User) GetPlan(db *sql.DB) channel.Plan {
|
||||
return channel.PlanInstance.GetPlan(u.GetSubscriptionLevel(db))
|
||||
}
|
||||
|
||||
func (u *User) GetSubscriptionExpiredAt(db *sql.DB) time.Time {
|
||||
@ -89,7 +95,7 @@ func (u *User) DowngradePlan(db *sql.DB, target int) error {
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
weight := GetLevel(current).Price / GetLevel(target).Price
|
||||
weight := channel.PlanInstance.GetPlan(current).Price / channel.PlanInstance.GetPlan(target).Price
|
||||
stamp := float32(expired.Unix()-now.Unix()) * weight
|
||||
|
||||
// ceil expired time
|
||||
@ -102,7 +108,7 @@ func (u *User) DowngradePlan(db *sql.DB, target int) error {
|
||||
|
||||
func (u *User) CountUpgradePrice(db *sql.DB, target int) float32 {
|
||||
expired := u.GetSubscriptionExpiredAt(db)
|
||||
weight := GetLevel(target).Price - u.GetPlan(db).Price
|
||||
weight := channel.PlanInstance.GetPlan(target).Price - u.GetPlan(db).Price
|
||||
if weight < 0 {
|
||||
return 0
|
||||
}
|
||||
@ -117,7 +123,7 @@ func (u *User) SetSubscriptionLevel(db *sql.DB, level int) bool {
|
||||
}
|
||||
|
||||
func CountSubscriptionPrize(level int, month int) float32 {
|
||||
plan := GetLevel(level)
|
||||
plan := channel.PlanInstance.GetPlan(level)
|
||||
base := plan.Price * float32(month)
|
||||
if month >= 36 {
|
||||
return base * 0.7
|
||||
@ -129,9 +135,13 @@ func CountSubscriptionPrize(level int, month int) float32 {
|
||||
return base
|
||||
}
|
||||
|
||||
func BuySubscription(db *sql.DB, cache *redis.Client, user *User, level int, month int) bool {
|
||||
if month < 1 || month > 999 || !InLevel(level) {
|
||||
return false
|
||||
func BuySubscription(db *sql.DB, cache *redis.Client, user *User, level int, month int) error {
|
||||
if disableSubscription() {
|
||||
return errors.New("subscription feature does not enable of this site")
|
||||
}
|
||||
|
||||
if month < 1 || month > 999 || !channel.IsValidPlan(level) {
|
||||
return errors.New("invalid subscription params")
|
||||
}
|
||||
|
||||
before := user.GetSubscriptionLevel(db)
|
||||
@ -152,34 +162,35 @@ func BuySubscription(db *sql.DB, cache *redis.Client, user *User, level int, mon
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
} else if before > level {
|
||||
// downgrade subscription
|
||||
err := user.DowngradePlan(db, level)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
|
||||
return err == nil
|
||||
return user.DowngradePlan(db, level)
|
||||
} else {
|
||||
// upgrade subscription
|
||||
money := user.CountUpgradePrice(db, level)
|
||||
if user.Pay(db, cache, money) {
|
||||
user.SetSubscriptionLevel(db, level)
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return errors.New("not enough money")
|
||||
}
|
||||
|
||||
func HandleSubscriptionUsage(db *sql.DB, cache *redis.Client, user *User, model string) bool {
|
||||
if disableSubscription() {
|
||||
return false
|
||||
}
|
||||
plan := user.GetPlan(db)
|
||||
return plan.IncreaseUsage(user, cache, model)
|
||||
}
|
||||
|
||||
func RevertSubscriptionUsage(db *sql.DB, cache *redis.Client, user *User, model string) bool {
|
||||
if disableSubscription() {
|
||||
return false
|
||||
}
|
||||
plan := user.GetPlan(db)
|
||||
return plan.DecreaseUsage(user, cache, model)
|
||||
}
|
||||
|
12
auth/usage.go
Normal file
12
auth/usage.go
Normal file
@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"chat/channel"
|
||||
"database/sql"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
func (u *User) GetSubscriptionUsage(db *sql.DB, cache *redis.Client) channel.UsageMap {
|
||||
plan := u.GetPlan(db)
|
||||
return plan.GetUsage(u, db, cache)
|
||||
}
|
@ -151,3 +151,24 @@ func UpdateConfig(c *gin.Context) {
|
||||
"error": utils.GetError(state),
|
||||
})
|
||||
}
|
||||
|
||||
func GetPlanConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, PlanInstance)
|
||||
}
|
||||
|
||||
func UpdatePlanConfig(c *gin.Context) {
|
||||
var config PlanManager
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
state := PlanInstance.UpdateConfig(&config)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": state == nil,
|
||||
"error": utils.GetError(state),
|
||||
})
|
||||
}
|
||||
|
@ -9,11 +9,13 @@ import (
|
||||
var ConduitInstance *Manager
|
||||
var ChargeInstance *ChargeManager
|
||||
var SystemInstance *SystemConfig
|
||||
var PlanInstance *PlanManager
|
||||
|
||||
func InitManager() {
|
||||
ConduitInstance = NewChannelManager()
|
||||
ChargeInstance = NewChargeManager()
|
||||
SystemInstance = NewSystemConfig()
|
||||
PlanInstance = NewPlanManager()
|
||||
}
|
||||
|
||||
func NewChannelManager() *Manager {
|
||||
|
@ -1,4 +1,4 @@
|
||||
package auth
|
||||
package channel
|
||||
|
||||
import (
|
||||
"chat/globals"
|
||||
@ -7,10 +7,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/spf13/viper"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlanManager struct {
|
||||
Enabled bool `json:"enabled" mapstructure:"enabled"`
|
||||
Plans []Plan `json:"plans" mapstructure:"plans"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Level int `json:"level" mapstructure:"level"`
|
||||
Price float32 `json:"price" mapstructure:"price"`
|
||||
@ -31,67 +37,64 @@ type Usage struct {
|
||||
}
|
||||
type UsageMap map[string]Usage
|
||||
|
||||
var GPT4Array = []string{
|
||||
globals.GPT4, globals.GPT40314, globals.GPT40613, globals.GPT41106Preview, globals.GPT41106VisionPreview,
|
||||
globals.GPT4Vision, globals.GPT4Dalle, globals.GPT4All,
|
||||
}
|
||||
|
||||
var ClaudeProArray = []string{
|
||||
globals.Claude1100k, globals.Claude2100k, globals.Claude2200k,
|
||||
}
|
||||
|
||||
var MidjourneyArray = []string{
|
||||
globals.MidjourneyFast,
|
||||
}
|
||||
|
||||
var Plans = []Plan{
|
||||
{
|
||||
Level: 0,
|
||||
Price: 0,
|
||||
Items: []PlanItem{},
|
||||
},
|
||||
{
|
||||
Level: 1,
|
||||
Price: 42,
|
||||
Items: []PlanItem{
|
||||
{Id: "gpt-4", Value: 150, Models: GPT4Array, Name: "GPT-4", Icon: "compass"},
|
||||
{Id: "midjourney", Value: 50, Models: MidjourneyArray, Name: "Midjourney", Icon: "image-plus"},
|
||||
{Id: "claude-100k", Value: 300, Models: ClaudeProArray, Name: "Claude 100k", Icon: "book-text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Level: 2,
|
||||
Price: 76,
|
||||
Items: []PlanItem{
|
||||
{Id: "gpt-4", Value: 300, Models: GPT4Array, Name: "GPT-4", Icon: "compass"},
|
||||
{Id: "midjourney", Value: 100, Models: MidjourneyArray, Name: "Midjourney", Icon: "image-plus"},
|
||||
{Id: "claude-100k", Value: 600, Models: ClaudeProArray, Name: "Claude 100k", Icon: "book-text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Level: 3,
|
||||
Price: 148,
|
||||
Items: []PlanItem{
|
||||
{Id: "gpt-4", Value: 600, Models: GPT4Array, Name: "GPT-4", Icon: "compass"},
|
||||
{Id: "midjourney", Value: 200, Models: MidjourneyArray, Name: "Midjourney", Icon: "image-plus"},
|
||||
{Id: "claude-100k", Value: 1200, Models: ClaudeProArray, Name: "Claude 100k", Icon: "book-text"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var planExp int64 = 0
|
||||
|
||||
func NewPlanManager() *PlanManager {
|
||||
manager := &PlanManager{}
|
||||
if err := viper.UnmarshalKey("subscription", manager); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func (c *PlanManager) SaveConfig() error {
|
||||
viper.Set("subscription", c)
|
||||
return viper.WriteConfig()
|
||||
}
|
||||
|
||||
func (c *PlanManager) UpdateConfig(data *PlanManager) error {
|
||||
c.Enabled = data.Enabled
|
||||
c.Plans = data.Plans
|
||||
return c.SaveConfig()
|
||||
}
|
||||
|
||||
func (c *PlanManager) GetPlan(level int) Plan {
|
||||
for _, plan := range c.Plans {
|
||||
if plan.Level == level {
|
||||
return plan
|
||||
}
|
||||
}
|
||||
return Plan{}
|
||||
}
|
||||
|
||||
func (c *PlanManager) GetPlans() []Plan {
|
||||
if c.Enabled {
|
||||
return c.Plans
|
||||
}
|
||||
|
||||
return []Plan{}
|
||||
}
|
||||
|
||||
func (c *PlanManager) GetRawPlans() []Plan {
|
||||
return c.Plans
|
||||
}
|
||||
|
||||
func (c *PlanManager) IsEnabled() bool {
|
||||
return c.Enabled
|
||||
}
|
||||
|
||||
func getOffsetFormat(offset time.Time, usage int64) string {
|
||||
return fmt.Sprintf("%s/%d", offset.Format("2006-01-02:15:04:05"), usage)
|
||||
}
|
||||
|
||||
func GetSubscriptionUsage(cache *redis.Client, user *User, t string) (usage int64, offset time.Time) {
|
||||
func GetSubscriptionUsage(cache *redis.Client, user globals.AuthLike, t string) (usage int64, offset time.Time) {
|
||||
// example cache value: 2021-09-01:19:00:00/100
|
||||
// if date is longer than 1 month, reset usage
|
||||
|
||||
offset = time.Now()
|
||||
|
||||
key := globals.GetSubscriptionLimitFormat(t, user.ID)
|
||||
key := globals.GetSubscriptionLimitFormat(t, user.HitID())
|
||||
v, err := utils.GetCache(cache, key)
|
||||
if (err != nil && errors.Is(err, redis.Nil)) || len(v) == 0 {
|
||||
usage = 0
|
||||
@ -141,8 +144,8 @@ func GetSubscriptionUsage(cache *redis.Client, user *User, t string) (usage int6
|
||||
return
|
||||
}
|
||||
|
||||
func IncreaseSubscriptionUsage(cache *redis.Client, user *User, t string, limit int64) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(t, user.ID)
|
||||
func IncreaseSubscriptionUsage(cache *redis.Client, user globals.AuthLike, t string, limit int64) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(t, user.HitID())
|
||||
usage, offset := GetSubscriptionUsage(cache, user, t)
|
||||
|
||||
usage += 1
|
||||
@ -155,8 +158,8 @@ func IncreaseSubscriptionUsage(cache *redis.Client, user *User, t string, limit
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func DecreaseSubscriptionUsage(cache *redis.Client, user *User, t string) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(t, user.ID)
|
||||
func DecreaseSubscriptionUsage(cache *redis.Client, user globals.AuthLike, t string) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(t, user.HitID())
|
||||
usage, offset := GetSubscriptionUsage(cache, user, t)
|
||||
|
||||
usage -= 1
|
||||
@ -169,35 +172,35 @@ func DecreaseSubscriptionUsage(cache *redis.Client, user *User, t string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (p *Plan) GetUsage(user *User, db *sql.DB, cache *redis.Client) UsageMap {
|
||||
func (p *Plan) GetUsage(user globals.AuthLike, db *sql.DB, cache *redis.Client) UsageMap {
|
||||
return utils.EachObject[PlanItem, Usage](p.Items, func(usage PlanItem) (string, Usage) {
|
||||
return usage.Id, usage.GetUsageForm(user, db, cache)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *PlanItem) GetUsage(user *User, db *sql.DB, cache *redis.Client) int64 {
|
||||
func (p *PlanItem) GetUsage(user globals.AuthLike, db *sql.DB, cache *redis.Client) int64 {
|
||||
// preflight check
|
||||
user.GetID(db)
|
||||
usage, _ := GetSubscriptionUsage(cache, user, p.Id)
|
||||
return usage
|
||||
}
|
||||
|
||||
func (p *PlanItem) ResetUsage(user *User, cache *redis.Client) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(p.Id, user.ID)
|
||||
func (p *PlanItem) ResetUsage(user globals.AuthLike, cache *redis.Client) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(p.Id, user.HitID())
|
||||
_, offset := GetSubscriptionUsage(cache, user, p.Id)
|
||||
|
||||
err := utils.SetCache(cache, key, getOffsetFormat(offset, 0), planExp)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (p *PlanItem) CreateUsage(user *User, cache *redis.Client) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(p.Id, user.ID)
|
||||
func (p *PlanItem) CreateUsage(user globals.AuthLike, cache *redis.Client) bool {
|
||||
key := globals.GetSubscriptionLimitFormat(p.Id, user.HitID())
|
||||
|
||||
err := utils.SetCache(cache, key, getOffsetFormat(time.Now(), 0), planExp)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (p *PlanItem) GetUsageForm(user *User, db *sql.DB, cache *redis.Client) Usage {
|
||||
func (p *PlanItem) GetUsageForm(user globals.AuthLike, db *sql.DB, cache *redis.Client) Usage {
|
||||
return Usage{
|
||||
Used: p.GetUsage(user, db, cache),
|
||||
Total: p.Value,
|
||||
@ -208,30 +211,25 @@ func (p *PlanItem) IsInfinity() bool {
|
||||
return p.Value == -1
|
||||
}
|
||||
|
||||
func (p *PlanItem) IsExceeded(user *User, db *sql.DB, cache *redis.Client) bool {
|
||||
func (p *PlanItem) IsExceeded(user globals.AuthLike, db *sql.DB, cache *redis.Client) bool {
|
||||
return p.IsInfinity() || p.GetUsage(user, db, cache) < p.Value
|
||||
}
|
||||
|
||||
func (p *PlanItem) Increase(user *User, cache *redis.Client) bool {
|
||||
func (p *PlanItem) Increase(user globals.AuthLike, cache *redis.Client) bool {
|
||||
if p.Value == -1 {
|
||||
return true
|
||||
}
|
||||
return IncreaseSubscriptionUsage(cache, user, p.Id, p.Value)
|
||||
}
|
||||
|
||||
func (p *PlanItem) Decrease(user *User, cache *redis.Client) bool {
|
||||
func (p *PlanItem) Decrease(user globals.AuthLike, cache *redis.Client) bool {
|
||||
if p.Value == -1 {
|
||||
return true
|
||||
}
|
||||
return DecreaseSubscriptionUsage(cache, user, p.Id)
|
||||
}
|
||||
|
||||
func (u *User) GetSubscriptionUsage(db *sql.DB, cache *redis.Client) UsageMap {
|
||||
plan := u.GetPlan(db)
|
||||
return plan.GetUsage(u, db, cache)
|
||||
}
|
||||
|
||||
func (p *Plan) IncreaseUsage(user *User, cache *redis.Client, model string) bool {
|
||||
func (p *Plan) IncreaseUsage(user globals.AuthLike, cache *redis.Client, model string) bool {
|
||||
for _, usage := range p.Items {
|
||||
if utils.Contains(model, usage.Models) {
|
||||
return usage.Increase(user, cache)
|
||||
@ -241,7 +239,7 @@ func (p *Plan) IncreaseUsage(user *User, cache *redis.Client, model string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Plan) DecreaseUsage(user *User, cache *redis.Client, model string) bool {
|
||||
func (p *Plan) DecreaseUsage(user globals.AuthLike, cache *redis.Client, model string) bool {
|
||||
for _, usage := range p.Items {
|
||||
if utils.Contains(model, usage.Models) {
|
||||
return usage.Decrease(user, cache)
|
||||
@ -251,15 +249,6 @@ func (p *Plan) DecreaseUsage(user *User, cache *redis.Client, model string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
func GetLevel(level int) Plan {
|
||||
for _, plan := range Plans {
|
||||
if plan.Level == level {
|
||||
return plan
|
||||
}
|
||||
}
|
||||
return Plan{}
|
||||
}
|
||||
|
||||
func InLevel(level int) bool {
|
||||
func IsValidPlan(level int) bool {
|
||||
return utils.InRange(level, 1, 3)
|
||||
}
|
@ -19,4 +19,7 @@ func Register(app *gin.RouterGroup) {
|
||||
|
||||
app.GET("/admin/config/view", GetConfig)
|
||||
app.POST("/admin/config/update", UpdateConfig)
|
||||
|
||||
app.GET("/admin/plan/view", GetPlanConfig)
|
||||
app.POST("/admin/plan/update", UpdatePlanConfig)
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
package globals
|
||||
|
||||
import "database/sql"
|
||||
|
||||
type ChannelConfig interface {
|
||||
GetType() string
|
||||
GetModelReflect(model string) string
|
||||
@ -9,3 +11,8 @@ type ChannelConfig interface {
|
||||
GetEndpoint() string
|
||||
ProcessError(err error) error
|
||||
}
|
||||
|
||||
type AuthLike interface {
|
||||
GetID(db *sql.DB) int64
|
||||
HitID() int64
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ package manager
|
||||
|
||||
import (
|
||||
"chat/admin"
|
||||
"chat/auth"
|
||||
"chat/channel"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
@ -21,7 +20,7 @@ func ChargeAPI(c *gin.Context) {
|
||||
}
|
||||
|
||||
func PlanAPI(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, auth.Plans)
|
||||
c.JSON(http.StatusOK, channel.PlanInstance.GetPlans())
|
||||
}
|
||||
|
||||
func sendErrorResponse(c *gin.Context, err error, types ...string) {
|
||||
|
Loading…
Reference in New Issue
Block a user