/* Copyright (C) 2025 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { Button, Col, Collapsible, Form, Radio, RadioGroup, Row, SideSheet, Spin, Switch, Tabs, Typography, } from '@douyinfe/semi-ui'; import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; import { IconHelpCircle } from '@douyinfe/semi-icons'; import { compareObjects, API, showError, showSuccess, showWarning, verifyJSON, } from '../../../helpers'; import { useTranslation } from 'react-i18next'; import GroupTable from './components/GroupTable'; import AutoGroupList from './components/AutoGroupList'; import GroupGroupRatioRules from './components/GroupGroupRatioRules'; import GroupSpecialUsableRules from './components/GroupSpecialUsableRules'; const { Text, Title, Paragraph } = Typography; const OPTION_KEYS = [ 'GroupRatio', 'UserUsableGroups', 'GroupGroupRatio', 'group_ratio_setting.group_special_usable_group', 'AutoGroups', 'DefaultUseAutoGroup', ]; function parseJSONSafe(str, fallback) { if (!str || !str.trim()) return fallback; try { return JSON.parse(str); } catch { return fallback; } } export default function GroupRatioSettings(props) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [editMode, setEditMode] = useState('visual'); const [showGuide, setShowGuide] = useState(false); const [inputs, setInputs] = useState({ GroupRatio: '', UserUsableGroups: '', GroupGroupRatio: '', 'group_ratio_setting.group_special_usable_group': '', AutoGroups: '', DefaultUseAutoGroup: false, }); const refForm = useRef(); const [inputsRow, setInputsRow] = useState(inputs); const dataVersionRef = useRef(0); const groupNames = useMemo(() => { const ratioMap = parseJSONSafe(inputs.GroupRatio, {}); return Object.keys(ratioMap); }, [inputs.GroupRatio]); async function onSubmit() { if (editMode === 'manual') { try { await refForm.current.validate(); } catch { showError(t('请检查输入')); return; } } const updateArray = compareObjects(inputs, inputsRow); if (!updateArray.length) { return showWarning(t('你似乎并没有修改什么')); } const requestQueue = updateArray.map((item) => { const value = typeof inputs[item.key] === 'boolean' ? String(inputs[item.key]) : inputs[item.key]; return API.put('/api/option/', { key: item.key, value }); }); setLoading(true); try { const res = await Promise.all(requestQueue); if (res.includes(undefined)) { return showError( requestQueue.length > 1 ? t('部分保存失败,请重试') : t('保存失败'), ); } for (let i = 0; i < res.length; i++) { if (!res[i].data.success) { return showError(res[i].data.message); } } showSuccess(t('保存成功')); props.refresh(); } catch (error) { console.error('Unexpected error:', error); showError(t('保存失败,请重试')); } finally { setLoading(false); } } useEffect(() => { const currentInputs = {}; for (let key in props.options) { if (OPTION_KEYS.includes(key)) { currentInputs[key] = props.options[key]; } } setInputs(currentInputs); setInputsRow(structuredClone(currentInputs)); dataVersionRef.current += 1; if (refForm.current) { refForm.current.setValues(currentInputs); } }, [props.options]); const handleGroupTableChange = useCallback( ({ GroupRatio, UserUsableGroups }) => { setInputs((prev) => ({ ...prev, GroupRatio, UserUsableGroups })); }, [], ); const handleAutoGroupsChange = useCallback((value) => { setInputs((prev) => ({ ...prev, AutoGroups: value })); }, []); const handleGroupGroupRatioChange = useCallback((value) => { setInputs((prev) => ({ ...prev, GroupGroupRatio: value })); }, []); const handleSpecialUsableChange = useCallback((value) => { setInputs((prev) => ({ ...prev, 'group_ratio_setting.group_special_usable_group': value, })); }, []); const dv = dataVersionRef.current; const renderVisualMode = () => (
{t('倍率用于计费乘数,勾选「用户可选」后用户可在创建令牌时选择该分组')} {t('令牌分组设为 auto 时,按以下顺序依次尝试选择可用分组,排在前面的优先级更高')}
setInputs((prev) => ({ ...prev, DefaultUseAutoGroup: value, })) } />
{t('开启后创建令牌默认选择auto分组,初始令牌也将设为auto')}
{t('当某个分组的用户使用另一个分组的令牌时,可设置特殊倍率覆盖基础倍率。例如:vip 分组的用户使用 default 分组时倍率为 0.5')} {t('为特定用户分组配置可用分组的增减规则。「添加」为该分组新增可用分组,「移除」移除默认可用分组,「追加」直接追加分组')}
); useEffect(() => { if (editMode === 'manual' && refForm.current) { refForm.current.setValues(inputs); } }, [editMode]); const renderManualMode = () => (
(refForm.current = formAPI)} style={{ marginBottom: 15 }} > verifyJSON(value), message: t('不是合法的 JSON 字符串'), }, ]} onChange={(value) => setInputs((prev) => ({ ...prev, GroupRatio: value })) } /> verifyJSON(value), message: t('不是合法的 JSON 字符串'), }, ]} onChange={(value) => setInputs((prev) => ({ ...prev, UserUsableGroups: value })) } /> verifyJSON(value), message: t('不是合法的 JSON 字符串'), }, ]} onChange={(value) => setInputs((prev) => ({ ...prev, GroupGroupRatio: value })) } /> verifyJSON(value), message: t('不是合法的 JSON 字符串'), }, ]} onChange={(value) => setInputs((prev) => ({ ...prev, 'group_ratio_setting.group_special_usable_group': value, })) } /> { if (!value || value.trim() === '') return true; try { const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return false; return parsed.every((item) => typeof item === 'string'); } catch { return false; } }, message: t( '必须是有效的 JSON 字符串数组,例如:["g1","g2"]', ), }, ]} onChange={(value) => setInputs((prev) => ({ ...prev, AutoGroups: value })) } /> setInputs((prev) => ({ ...prev, DefaultUseAutoGroup: value, })) } />
); const GuideSection = ({ title, children }) => { const [open, setOpen] = useState(false); return (
{children}
); }; const CodeBlock = ({ children }) => (
      {children}
    
); const renderGuide = () => ( setShowGuide(false)} width={560} bodyStyle={{ overflow: 'auto', padding: '0 24px 24px' }} >
{t('什么是分组?')} {t( '分组是用于控制计费倍率和模型访问权限的核心概念。每个用户属于一个分组,每个令牌也可以指定使用某个分组。', )} {t( '通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。', )} {t('用户分组')}{' — '} {t('由管理员分配,决定用户身份等级(如 default、vip)。')} {t('令牌分组')}{' — '} {t('用户创建令牌时选择的分组,决定该令牌的实际计费倍率。一个用户可以创建多个令牌,使用不同分组。')} {t('倍率')}{' — '} {t('计费乘数,倍率越低费用越低。例如倍率 0.5 表示半价。')} {t('用户可选')}{' — '} {t('勾选后,该分组会出现在用户创建令牌时的下拉菜单中。未勾选的分组只能由管理员分配,用户自己无法选择。')} {t('自动分组')}{' — '} {t('令牌分组设为 auto 时,系统按优先级顺序自动选择一个可用分组。')}
{t('创建和管理分组')} {t('每个分组代表一个价格档位。管理员创建分组后,可以选择哪些档位对用户开放自选。')} {t('场景:站点提供两个价格档位,用户可以按需选择')} {`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`} {t('两个分组都勾选了「用户可选」,所以用户创建令牌时可以看到这两个选项:')} {t('用户创建令牌 → 选择分组下拉框:')}{'\n'} {` ├─ standard (${t('标准价格')})`}{'\n'} {` └─ premium (${t('高级套餐,半价优惠')})`} {t('选择 premium 创建的令牌,调用 API 时费用为 standard 的 50%。')} {t('对比:不勾选「用户可选」的场景')} {t('假设再加两个分组 default 和 vip,但不勾选用户可选:')} {`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\ndefault 1.0 ${t('否')} ${t('管理员分配的基础分组')}\nvip 0.5 ${t('否')} ${t('管理员分配的优惠分组')}\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`} {t('此时用户创建令牌时只能看到 standard 和 premium:')} {t('用户创建令牌 → 选择分组下拉框:')}{'\n'} {` ├─ standard (${t('标准价格')})`}{'\n'} {` └─ premium (${t('高级套餐,半价优惠')})`}{'\n\n'} {` ${t('不会出现')} default ${t('和')} vip`} {t('default 和 vip 只能由管理员在「用户管理」中分配给用户。适用于按用户等级定价、内部测试等不希望用户自主选择的场景。')} {t('用户分组的联动作用')} {t('管理员给用户分配的分组(如 vip)不仅决定用户身份,还会影响后续两个功能:')} {'1. '}{t('特殊倍率')}{' — '} {t('可以根据用户分组设置不同的计费倍率。例如 vip 用户使用 standard 令牌时倍率从 1.0 降为 0.8。')} {'2. '}{t('可用分组')}{' — '} {t('可以根据用户分组增减令牌可选的分组范围。例如 vip 用户额外开放 premium 分组,或移除某个分组的选择权。')} {t('详见「特殊倍率」和「可用分组」标签页。')} GroupRatio{' — '}{t('分组名称到倍率的映射')} {`{"default": 1, "vip": 0.5, "standard": 1, "premium": 0.5}`} UserUsableGroups{' — '}{t('用户可选分组的名称和描述(只包含勾选了用户可选的分组)')} {`{"standard": "${t('标准价格')}", "premium": "${t('高级套餐,半价优惠')}"}`}
{t('自动分组选择')} {t('当令牌分组设为 auto 时,系统按列表顺序依次选择可用分组。排在前面的优先级更高。')} {t('场景:设置自动选择优先级')} {`1. default ${t('最高优先级')}\n2. vip`} {t('开启「默认使用 auto 分组」后,新建令牌和初始令牌都会自动设为 auto。')} AutoGroups{' — '}{t('有序字符串数组')} {`["default", "vip"]`}
{t('跨分组特殊倍率')} {t('正常情况下,令牌的计费倍率由令牌所选的分组决定。特殊倍率可以根据「用户所在分组」进一步覆盖这个倍率。')} {t('简单来说:同一个令牌分组,不同等级的用户可以享受不同的价格。')} {t('场景:站点有 standard(倍率 1.0)和 premium(倍率 0.5)两个分组,希望 vip 用户使用 standard 令牌时也能享受折扣')} {t('不配置特殊倍率时:')} {`${t('普通用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0 (${t('原价')})\nvip ${t('用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0 (${t('原价,和普通用户一样')})`} {t('配置特殊倍率后:')} {`${t('用户分组')} ${t('使用分组')} ${t('倍率')}\n────────────────────────────\nvip standard 0.8\nvip premium 0.3`} {t('配置后的效果:')} {`${t('普通用户')} + standard ${t('令牌')} → ${t('倍率')} 1.0 (${t('不变')})\nvip ${t('用户')} + standard ${t('令牌')} → ${t('倍率')} 0.8 (${t('享受 8 折')})\nvip ${t('用户')} + premium ${t('令牌')} → ${t('倍率')} 0.3 (${t('从 0.5 降到 0.3')})`} {t('只有配置了规则的组合才会覆盖,未配置的组合仍使用令牌分组的基础倍率。')} GroupGroupRatio{' — '}{t('嵌套映射:用户分组 → 使用分组 → 倍率')} {`{\n "vip": {\n "standard": 0.8,\n "premium": 0.3\n }\n}`}
{t('特殊可用分组规则')} {t('默认情况下,所有用户创建令牌时看到的可选分组列表是一样的(即「用户可选」列勾选的分组)。')} {t('通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。')} {t('场景:站点有 standard 和 premium 两个用户可选分组。希望 vip 用户额外看到 exclusive 分组,同时不再看到 standard 分组')} {t('不配置规则时,所有用户看到的下拉框一样:')} {`${t('所有用户')} → ${t('创建令牌可选')}:\n ├─ standard\n └─ premium`} {t('为 vip 用户配置规则:')} {`${t('用户分组')} ${t('操作')} ${t('目标分组')} ${t('描述')}\n──────────────────────────────────────────\nvip ${t('添加')} (+:) exclusive ${t('专属分组')}\nvip ${t('移除')} (-:) standard -`} {t('配置后的效果:')} {`${t('普通用户')} → ${t('创建令牌可选')}:\n ├─ standard\n └─ premium\n\nvip ${t('用户')} → ${t('创建令牌可选')}:\n ├─ premium (${t('保留')})\n └─ exclusive (${t('新增')})\n\n ${t('standard 已被移除,vip 用户看不到')}`} {t('三种操作的区别:')} {`${t('添加')} (+:) → ${t('在默认列表基础上新增一个分组')}\n${t('移除')} (-:) → ${t('从默认列表中去掉一个分组')}\n${t('追加')} → ${t('直接追加(和添加类似,但无前缀)')}`} group_special_usable_group {`{\n "vip": {\n "+:exclusive": "${t('专属分组')}",\n "-:standard": "remove"\n }\n}`} {t('键的前缀 +: 表示添加,-: 表示移除,无前缀表示追加。值为分组描述(移除时填 "remove")。')}
); return (
setEditMode(e.target.value)} > {t('可视化编辑')} {t('手动编辑')}
{editMode === 'visual' ? renderVisualMode() : renderManualMode()}
{renderGuide()}
); }