refactor(settings): update RatioSetting component to use ModelPricingCombined and adjust tab structure

- Replaced ModelRatioSettings with ModelPricingCombined in the RatioSetting component.
- Updated tab structure to prioritize pricing settings over model settings.
- Removed unused imports for ModelRatioSettings and ModelSettingsVisualEditor.
This commit is contained in:
CaIon
2026-04-08 00:59:50 +08:00
parent 960bf9c49e
commit dc83c4af31
17 changed files with 4382 additions and 2244 deletions
+4 -9
View File
@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
@@ -95,18 +94,14 @@ const RatioSetting = () => {
return (
<Spin spinning={loading} size='large'>
{/* 模型倍率设置以及价格编辑器 */}
<Card style={{ marginTop: '10px' }}>
<Tabs type='card' defaultActiveKey='visual'>
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
<ModelRatioSettings options={inputs} refresh={onRefresh} />
<Tabs type='card' defaultActiveKey='pricing'>
<Tabs.TabPane tab={t('模型定价设置')} itemKey='pricing'>
<ModelPricingCombined options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
<GroupRatioSettings options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
<Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
</Tabs.TabPane>
@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
};
// Render group column
const renderGroupColumn = (text, record, t) => {
const renderGroupColumn = (text, record, t, groupRatios = {}) => {
if (text === 'auto') {
return (
<Tooltip
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
</Tooltip>
);
}
return renderGroup(text);
const ratio = groupRatios[text];
return (
<span className='flex items-center gap-1'>
{renderGroup(text)}
{ratio !== undefined && (
<Tag size='small' color='green' shape='circle'>
{ratio}x
</Tag>
)}
</span>
);
};
// Render token key column with show/hide and copy functionality
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
setEditingToken,
setShowEdit,
refresh,
groupRatios = {},
}) => {
return [
{
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
title: t('分组'),
dataIndex: 'group',
key: 'group',
render: (text, record) => renderGroupColumn(text, record, t),
render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
},
{
title: t('密钥'),
@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
t,
} = tokensData;
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
});
}, [
t,
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
setEditingToken,
setShowEdit,
refresh,
groupRatios,
]);
// Handle compact mode by removing fixed positioning
@@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
placeholder={t('令牌分组,默认为用户的分组')}
optionList={groups}
renderOptionItem={renderGroupOption}
filter={(input, option) => {
const q = input.toLowerCase();
return (
option.value?.toLowerCase().includes(q) ||
(typeof option.label === 'string' &&
option.label.toLowerCase().includes(q))
);
}}
showClear
style={{ width: '100%' }}
/>
+13
View File
@@ -42,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
// Basic state
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [groupRatios, setGroupRatios] = useState({});
const [activePage, setActivePage] = useState(1);
const [tokenCount, setTokenCount] = useState(0);
const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@@ -437,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
.catch((reason) => {
showError(reason);
});
API.get('/api/user/self/groups')
.then((res) => {
if (res.data.success && res.data.data) {
const ratios = {};
for (const [name, info] of Object.entries(res.data.data)) {
ratios[name] = info.ratio;
}
setGroupRatios(ratios);
}
})
.catch(() => {});
}, [pageSize]);
return {
@@ -447,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
tokenCount,
pageSize,
searching,
groupRatios,
// Selection state
selectedKeys,
+430 -317
View File
File diff suppressed because it is too large Load Diff
+442 -329
View File
File diff suppressed because it is too large Load Diff
+435 -322
View File
File diff suppressed because it is too large Load Diff
+439 -326
View File
File diff suppressed because it is too large Load Diff
+428 -316
View File
File diff suppressed because it is too large Load Diff
+652 -538
View File
File diff suppressed because it is too large Load Diff
@@ -17,8 +17,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useEffect, useState, useRef } from 'react';
import { Button, Col, Form, Row, Spin } from '@douyinfe/semi-ui';
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,
@@ -28,10 +43,37 @@ import {
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: '',
@@ -42,80 +84,189 @@ export default function GroupRatioSettings(props) {
});
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 {
await refForm.current
.validate()
.then(() => {
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);
Promise.all(requestQueue)
.then((res) => {
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);
});
})
.catch(() => {
showError(t('请检查输入'));
});
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) {
showError(t('请检查输入'));
console.error(error);
console.error('Unexpected error:', error);
showError(t('保存失败,请重试'));
} finally {
setLoading(false);
}
}
useEffect(() => {
const currentInputs = {};
for (let key in props.options) {
if (Object.keys(inputs).includes(key)) {
if (OPTION_KEYS.includes(key)) {
currentInputs[key] = props.options[key];
}
}
setInputs(currentInputs);
setInputsRow(structuredClone(currentInputs));
refForm.current.setValues(currentInputs);
dataVersionRef.current += 1;
if (refForm.current) {
refForm.current.setValues(currentInputs);
}
}, [props.options]);
return (
<Spin spinning={loading}>
<Form
values={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
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 = () => (
<Form key='form-visual' values={inputs} style={{ marginBottom: 15 }}>
<Form.Section text={t('分组管理')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('倍率用于计费乘数,勾选「用户可选」后用户可在创建令牌时选择该分组')}
</Text>
<GroupTable
key={`gt_${dv}`}
groupRatio={inputs.GroupRatio}
userUsableGroups={inputs.UserUsableGroups}
onChange={handleGroupTableChange}
/>
</Form.Section>
<Form.Section text={t('自动分组')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('令牌分组设为 auto 时,按以下顺序依次尝试选择可用分组,排在前面的优先级更高')}
</Text>
<Row gutter={16}>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Slot label={t('默认使用auto分组')}>
<div className='flex items-center gap-2'>
<Switch
checked={!!inputs.DefaultUseAutoGroup}
size='default'
checkedText=''
uncheckedText=''
onChange={(value) =>
setInputs((prev) => ({
...prev,
DefaultUseAutoGroup: value,
}))
}
/>
</div>
<Text type='tertiary' size='small' style={{ marginTop: 4 }}>
{t('开启后创建令牌默认选择auto分组,初始令牌也将设为auto')}
</Text>
</Form.Slot>
</Col>
</Row>
<AutoGroupList
key={`ag_${dv}`}
value={inputs.AutoGroups}
groupNames={groupNames}
onChange={handleAutoGroupsChange}
/>
</Form.Section>
<Form.Section text={t('分组特殊倍率')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('当某个分组的用户使用另一个分组的令牌时,可设置特殊倍率覆盖基础倍率。例如:vip 分组的用户使用 default 分组时倍率为 0.5')}
</Text>
<GroupGroupRatioRules
key={`ggr_${dv}`}
value={inputs.GroupGroupRatio}
groupNames={groupNames}
onChange={handleGroupGroupRatioChange}
/>
</Form.Section>
<Form.Section text={t('分组特殊可用分组')}>
<Text type='tertiary' size='small' style={{ display: 'block', marginBottom: 12 }}>
{t('为特定用户分组配置可用分组的增减规则。「添加」为该分组新增可用分组,「移除」移除默认可用分组,「追加」直接追加分组')}
</Text>
<GroupSpecialUsableRules
key={`gsu_${dv}`}
value={inputs['group_ratio_setting.group_special_usable_group']}
groupNames={groupNames}
onChange={handleSpecialUsableChange}
/>
</Form.Section>
</Form>
);
useEffect(() => {
if (editMode === 'manual' && refForm.current) {
refForm.current.setValues(inputs);
}
}, [editMode]);
const renderManualMode = () => (
<Form
key='form-manual'
initValues={inputs}
getFormApi={(formAPI) => (refForm.current = formAPI)}
style={{ marginBottom: 15 }}
>
<Form.Section text={t('分组JSON设置')}>
<Row gutter={16}>
<Col xs={24} sm={16}>
<Form.TextArea
@@ -134,7 +285,9 @@ export default function GroupRatioSettings(props) {
message: t('不是合法的 JSON 字符串'),
},
]}
onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
onChange={(value) =>
setInputs((prev) => ({ ...prev, GroupRatio: value }))
}
/>
</Col>
</Row>
@@ -142,7 +295,9 @@ export default function GroupRatioSettings(props) {
<Col xs={24} sm={16}>
<Form.TextArea
label={t('用户可选分组')}
placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
placeholder={t(
'为一个 JSON 文本,键为分组名称,值为分组描述',
)}
extraText={t(
'用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
)}
@@ -157,7 +312,7 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({ ...inputs, UserUsableGroups: value })
setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
}
/>
</Col>
@@ -181,7 +336,7 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({ ...inputs, GroupGroupRatio: value })
setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
}
/>
</Col>
@@ -205,10 +360,10 @@ export default function GroupRatioSettings(props) {
},
]}
onChange={(value) =>
setInputs({
...inputs,
setInputs((prev) => ({
...prev,
'group_ratio_setting.group_special_usable_group': value,
})
}))
}
/>
</Col>
@@ -225,29 +380,23 @@ export default function GroupRatioSettings(props) {
rules={[
{
validator: (rule, value) => {
if (!value || value.trim() === '') {
return true; // Allow empty values
}
// First check if it's valid JSON
if (!value || value.trim() === '') return true;
try {
const parsed = JSON.parse(value);
// Check if it's an array
if (!Array.isArray(parsed)) {
return false;
}
// Check if every element is a string
if (!Array.isArray(parsed)) return false;
return parsed.every((item) => typeof item === 'string');
} catch (error) {
} catch {
return false;
}
},
message: t('必须是有效的 JSON 字符串数组,例如:["g1","g2"]'),
message: t(
'必须是有效的 JSON 字符串数组,例如:["g1","g2"]',
),
},
]}
onChange={(value) => setInputs({ ...inputs, AutoGroups: value })}
onChange={(value) =>
setInputs((prev) => ({ ...prev, AutoGroups: value }))
}
/>
</Col>
</Row>
@@ -259,13 +408,351 @@ export default function GroupRatioSettings(props) {
)}
field={'DefaultUseAutoGroup'}
onChange={(value) =>
setInputs({ ...inputs, DefaultUseAutoGroup: value })
setInputs((prev) => ({
...prev,
DefaultUseAutoGroup: value,
}))
}
/>
</Col>
</Row>
</Form>
<Button onClick={onSubmit}>{t('保存分组相关设置')}</Button>
</Form.Section>
</Form>
);
const GuideSection = ({ title, children }) => {
const [open, setOpen] = useState(false);
return (
<div style={{ marginTop: 16 }}>
<Button
theme='borderless'
size='small'
icon={open ? <IconChevronUp /> : <IconChevronDown />}
onClick={() => setOpen(!open)}
style={{ padding: '4px 0', color: 'var(--semi-color-primary)' }}
>
{title}
</Button>
<Collapsible isOpen={open} keepDOM>
<div
style={{
background: 'var(--semi-color-fill-0)',
padding: '12px 16px',
borderRadius: 8,
marginTop: 8,
}}
>
{children}
</div>
</Collapsible>
</div>
);
};
const CodeBlock = ({ children }) => (
<pre
style={{
background: 'var(--semi-color-bg-2)',
border: '1px solid var(--semi-color-border)',
padding: '10px 14px',
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 13,
margin: '8px 0',
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
overflowX: 'auto',
}}
>
{children}
</pre>
);
const renderGuide = () => (
<SideSheet
title={t('分组设置使用说明')}
visible={showGuide}
onCancel={() => setShowGuide(false)}
width={560}
bodyStyle={{ overflow: 'auto', padding: '0 24px 24px' }}
>
<Tabs type='line' size='small'>
<Tabs.TabPane tab={t('概览')} itemKey='overview'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('什么是分组?')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t(
'分组是用于控制计费倍率和模型访问权限的核心概念。每个用户属于一个分组,每个令牌也可以指定使用某个分组。',
)}
</Paragraph>
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
{t(
'通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。',
)}
</Paragraph>
<GuideSection title={t('核心概念')}>
<Paragraph style={{ lineHeight: 1.8 }}>
<Text strong>{t('用户分组')}</Text>{' — '}
{t('由管理员分配,决定用户身份等级(如 default、vip)。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('令牌分组')}</Text>{' — '}
{t('用户创建令牌时选择的分组,决定该令牌的实际计费倍率。一个用户可以创建多个令牌,使用不同分组。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('倍率')}</Text>{' — '}
{t('计费乘数,倍率越低费用越低。例如倍率 0.5 表示半价。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('用户可选')}</Text>{' — '}
{t('勾选后,该分组会出现在用户创建令牌时的下拉菜单中。未勾选的分组只能由管理员分配,用户自己无法选择。')}
</Paragraph>
<Paragraph style={{ lineHeight: 1.8, marginTop: 4 }}>
<Text strong>{t('自动分组')}</Text>{' — '}
{t('令牌分组设为 auto 时,系统按优先级顺序自动选择一个可用分组。')}
</Paragraph>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('分组管理')} itemKey='groups'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('创建和管理分组')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('每个分组代表一个价格档位。管理员创建分组后,可以选择哪些档位对用户开放自选。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
{t('场景:站点提供两个价格档位,用户可以按需选择')}
</Paragraph>
<CodeBlock>
{`${t('分组名')} ${t('倍率')} ${t('用户可选')} ${t('说明')}\n──────────────────────────────────────\nstandard 1.0 ${t('是')} ${t('标准价格')}\npremium 0.5 ${t('是')} ${t('高级套餐,半价优惠')}`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('两个分组都勾选了「用户可选」,所以用户创建令牌时可以看到这两个选项:')}
</Paragraph>
<CodeBlock>
{t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
{` ├─ standard (${t('标准价格')})`}{'\n'}
{` └─ premium (${t('高级套餐,半价优惠')})`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('选择 premium 创建的令牌,调用 API 时费用为 standard 的 50%。')}
</Paragraph>
<Paragraph size='small' style={{ marginTop: 16, lineHeight: 1.8 }}>
<Text strong>{t('对比:不勾选「用户可选」的场景')}</Text>
</Paragraph>
<Paragraph size='small' style={{ marginTop: 4, lineHeight: 1.8 }}>
{t('假设再加两个分组 default 和 vip,但不勾选用户可选:')}
</Paragraph>
<CodeBlock>
{`${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('高级套餐,半价优惠')}`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('此时用户创建令牌时只能看到 standard 和 premium')}
</Paragraph>
<CodeBlock>
{t('用户创建令牌 → 选择分组下拉框:')}{'\n'}
{` ├─ standard (${t('标准价格')})`}{'\n'}
{` └─ premium (${t('高级套餐,半价优惠')})`}{'\n\n'}
{` ${t('不会出现')} default ${t('和')} vip`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('default 和 vip 只能由管理员在「用户管理」中分配给用户。适用于按用户等级定价、内部测试等不希望用户自主选择的场景。')}
</Paragraph>
<Paragraph size='small' style={{ marginTop: 12, lineHeight: 1.8 }}>
<Text strong>{t('用户分组的联动作用')}</Text>
</Paragraph>
<Paragraph size='small' style={{ lineHeight: 1.8 }}>
{t('管理员给用户分配的分组(如 vip)不仅决定用户身份,还会影响后续两个功能:')}
</Paragraph>
<Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 4 }}>
{'1. '}<Text strong>{t('特殊倍率')}</Text>{' — '}
{t('可以根据用户分组设置不同的计费倍率。例如 vip 用户使用 standard 令牌时倍率从 1.0 降为 0.8。')}
</Paragraph>
<Paragraph size='small' style={{ lineHeight: 1.8, marginTop: 2 }}>
{'2. '}<Text strong>{t('可用分组')}</Text>{' — '}
{t('可以根据用户分组增减令牌可选的分组范围。例如 vip 用户额外开放 premium 分组,或移除某个分组的选择权。')}
</Paragraph>
<Paragraph size='small' type='tertiary' style={{ lineHeight: 1.8, marginTop: 6 }}>
{t('详见「特殊倍率」和「可用分组」标签页。')}
</Paragraph>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>GroupRatio</Text>{' — '}{t('分组名称到倍率的映射')}
</Paragraph>
<CodeBlock>{`{"default": 1, "vip": 0.5, "standard": 1, "premium": 0.5}`}</CodeBlock>
<Paragraph size='small' style={{ marginBottom: 4, marginTop: 8 }}>
<Text strong code>UserUsableGroups</Text>{' — '}{t('用户可选分组的名称和描述(只包含勾选了用户可选的分组)')}
</Paragraph>
<CodeBlock>{`{"standard": "${t('标准价格')}", "premium": "${t('高级套餐,半价优惠')}"}`}</CodeBlock>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('自动分组')} itemKey='auto'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('自动分组选择')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('当令牌分组设为 auto 时,系统按列表顺序依次选择可用分组。排在前面的优先级更高。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 6 }}>
{t('场景:设置自动选择优先级')}
</Paragraph>
<CodeBlock>
{`1. default ${t('最高优先级')}\n2. vip`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 6, lineHeight: 1.6 }}>
{t('开启「默认使用 auto 分组」后,新建令牌和初始令牌都会自动设为 auto。')}
</Paragraph>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>AutoGroups</Text>{' — '}{t('有序字符串数组')}
</Paragraph>
<CodeBlock>{`["default", "vip"]`}</CodeBlock>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('特殊倍率')} itemKey='ratios'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('跨分组特殊倍率')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('正常情况下,令牌的计费倍率由令牌所选的分组决定。特殊倍率可以根据「用户所在分组」进一步覆盖这个倍率。')}
</Paragraph>
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('简单来说:同一个令牌分组,不同等级的用户可以享受不同的价格。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
{t('场景:站点有 standard(倍率 1.0)和 premium(倍率 0.5)两个分组,希望 vip 用户使用 standard 令牌时也能享受折扣')}
</Paragraph>
<Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('不配置特殊倍率时:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('普通用户')} + standard ${t('令牌')}${t('倍率')} 1.0 (${t('原价')})\nvip ${t('用户')} + standard ${t('令牌')}${t('倍率')} 1.0 (${t('原价,和普通用户一样')})`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('配置特殊倍率后:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('用户分组')} ${t('使用分组')} ${t('倍率')}\n────────────────────────────\nvip standard 0.8\nvip premium 0.3`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('配置后的效果:')}
</Paragraph>
<CodeBlock>
{`${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')})`}
</CodeBlock>
<Paragraph size='small' type='tertiary' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('只有配置了规则的组合才会覆盖,未配置的组合仍使用令牌分组的基础倍率。')}
</Paragraph>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>GroupGroupRatio</Text>{' — '}{t('嵌套映射:用户分组 → 使用分组 → 倍率')}
</Paragraph>
<CodeBlock>{`{\n "vip": {\n "standard": 0.8,\n "premium": 0.3\n }\n}`}</CodeBlock>
</GuideSection>
</div>
</Tabs.TabPane>
<Tabs.TabPane tab={t('可用分组')} itemKey='usable'>
<div style={{ paddingTop: 20 }}>
<Title heading={5}>{t('特殊可用分组规则')}</Title>
<Paragraph style={{ marginTop: 12, lineHeight: 1.8 }}>
{t('默认情况下,所有用户创建令牌时看到的可选分组列表是一样的(即「用户可选」列勾选的分组)。')}
</Paragraph>
<Paragraph style={{ marginTop: 8, lineHeight: 1.8 }}>
{t('通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。')}
</Paragraph>
<GuideSection title={t('查看示例')}>
<Paragraph size='small' type='tertiary' style={{ marginBottom: 8 }}>
{t('场景:站点有 standard 和 premium 两个用户可选分组。希望 vip 用户额外看到 exclusive 分组,同时不再看到 standard 分组')}
</Paragraph>
<Paragraph size='small' style={{ marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('不配置规则时,所有用户看到的下拉框一样:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('所有用户')}${t('创建令牌可选')}:\n ├─ standard\n └─ premium`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, marginBottom: 8, lineHeight: 1.8 }}>
<Text strong>{t('为 vip 用户配置规则:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('用户分组')} ${t('操作')} ${t('目标分组')} ${t('描述')}\n──────────────────────────────────────────\nvip ${t('添加')} (+:) exclusive ${t('专属分组')}\nvip ${t('移除')} (-:) standard -`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 10, lineHeight: 1.8 }}>
{t('配置后的效果:')}
</Paragraph>
<CodeBlock>
{`${t('普通用户')}${t('创建令牌可选')}:\n ├─ standard\n └─ premium\n\nvip ${t('用户')}${t('创建令牌可选')}:\n ├─ premium (${t('保留')})\n └─ exclusive (${t('新增')})\n\n ${t('standard 已被移除,vip 用户看不到')}`}
</CodeBlock>
<Paragraph size='small' style={{ marginTop: 14, lineHeight: 1.8 }}>
<Text strong>{t('三种操作的区别:')}</Text>
</Paragraph>
<CodeBlock>
{`${t('添加')} (+:) → ${t('在默认列表基础上新增一个分组')}\n${t('移除')} (-:) → ${t('从默认列表中去掉一个分组')}\n${t('追加')}${t('直接追加(和添加类似,但无前缀)')}`}
</CodeBlock>
</GuideSection>
<GuideSection title={t('JSON 格式参考')}>
<Paragraph size='small' style={{ marginBottom: 4 }}>
<Text strong code>group_special_usable_group</Text>
</Paragraph>
<CodeBlock>{`{\n "vip": {\n "+:exclusive": "${t('专属分组')}",\n "-:standard": "remove"\n }\n}`}</CodeBlock>
<Paragraph size='small' type='tertiary' style={{ marginTop: 6, lineHeight: 1.6 }}>
{t('键的前缀 +: 表示添加,-: 表示移除,无前缀表示追加。值为分组描述(移除时填 "remove")。')}
</Paragraph>
</GuideSection>
</div>
</Tabs.TabPane>
</Tabs>
</SideSheet>
);
return (
<Spin spinning={loading}>
<div style={{ marginBottom: 15 }}>
<div className='flex items-center gap-3' style={{ marginTop: 12, marginBottom: 16 }}>
<RadioGroup
type='button'
size='small'
value={editMode}
onChange={(e) => setEditMode(e.target.value)}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='manual'>{t('手动编辑')}</Radio>
</RadioGroup>
<Button
icon={<IconHelpCircle />}
theme='borderless'
type='tertiary'
size='small'
onClick={() => setShowGuide(true)}
>
{t('使用说明')}
</Button>
</div>
{editMode === 'visual' ? renderVisualMode() : renderManualMode()}
</div>
<Button size='default' onClick={onSubmit}>
{t('保存分组相关设置')}
</Button>
{renderGuide()}
</Spin>
);
}
@@ -0,0 +1,50 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState } from 'react';
import { Radio, RadioGroup } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import ModelPricingEditor from './components/ModelPricingEditor';
import ModelRatioSettings from './ModelRatioSettings';
export default function ModelPricingCombined({ options, refresh }) {
const { t } = useTranslation();
const [editMode, setEditMode] = useState('visual');
return (
<div>
<div style={{ marginTop: 12, marginBottom: 16 }}>
<RadioGroup
type='button'
size='small'
value={editMode}
onChange={(e) => setEditMode(e.target.value)}
>
<Radio value='visual'>{t('可视化编辑')}</Radio>
<Radio value='manual'>{t('手动编辑')}</Radio>
</RadioGroup>
</div>
{editMode === 'visual' ? (
<ModelPricingEditor options={options} refresh={refresh} />
) : (
<ModelRatioSettings options={options} refresh={refresh} />
)}
</div>
);
}
@@ -0,0 +1,169 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Select,
Typography,
Popconfirm,
Tag,
} from '@douyinfe/semi-ui';
import {
IconPlus,
IconDelete,
IconChevronUp,
IconChevronDown,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `ag_${++_idCounter}`;
function parseAutoGroups(str) {
if (!str || !str.trim()) return [];
try {
const parsed = JSON.parse(str);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => typeof item === 'string')
.map((name) => ({ _id: uid(), name }));
} catch {
return [];
}
}
function serializeAutoGroups(items) {
const names = items.map((i) => i.name).filter(Boolean);
return names.length === 0 ? '' : JSON.stringify(names);
}
export default function AutoGroupList({ value, groupNames = [], onChange }) {
const { t } = useTranslation();
const [items, setItems] = useState(() => parseAutoGroups(value));
const emitChange = useCallback(
(newItems) => {
setItems(newItems);
onChange?.(serializeAutoGroups(newItems));
},
[onChange],
);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const addItem = useCallback(() => {
emitChange([...items, { _id: uid(), name: '' }]);
}, [items, emitChange]);
const removeItem = useCallback(
(id) => {
emitChange(items.filter((i) => i._id !== id));
},
[items, emitChange],
);
const updateItem = useCallback(
(id, name) => {
emitChange(items.map((i) => (i._id === id ? { ...i, name } : i)));
},
[items, emitChange],
);
const moveUp = useCallback(
(index) => {
if (index <= 0) return;
const next = [...items];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
emitChange(next);
},
[items, emitChange],
);
const moveDown = useCallback(
(index) => {
if (index >= items.length - 1) return;
const next = [...items];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
emitChange(next);
},
[items, emitChange],
);
if (items.length === 0) {
return (
<div>
<Text type='tertiary' className='block text-center py-4'>
{t('暂无自动分组,点击下方按钮添加')}
</Text>
<div className='mt-2 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
{t('添加分组')}
</Button>
</div>
</div>
);
}
return (
<div>
<div className='space-y-2'>
{items.map((item, index) => (
<div
key={item._id}
className='flex items-center gap-2'
>
<Tag size='small' color='blue' className='shrink-0'>
{index + 1}
</Tag>
<Select
size='small'
filter
value={item.name || undefined}
placeholder={t('选择分组')}
optionList={groupOptions}
onChange={(v) => updateItem(item._id, v)}
style={{ flex: 1 }}
allowCreate
position='bottomLeft'
/>
<Button
icon={<IconChevronUp />}
theme='borderless'
size='small'
disabled={index === 0}
onClick={() => moveUp(index)}
/>
<Button
icon={<IconChevronDown />}
theme='borderless'
size='small'
disabled={index === items.length - 1}
onClick={() => moveDown(index)}
/>
<Popconfirm
title={t('确认移除?')}
onConfirm={() => removeItem(item._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
</div>
))}
</div>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addItem}>
{t('添加分组')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,206 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
InputNumber,
Select,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `ggr_${++_idCounter}`;
function parseJSON(str) {
if (!str || !str.trim()) return {};
try {
return JSON.parse(str);
} catch {
return {};
}
}
function flattenRules(nested) {
const rules = [];
for (const [userGroup, inner] of Object.entries(nested)) {
if (typeof inner !== 'object' || inner === null) continue;
for (const [usingGroup, ratio] of Object.entries(inner)) {
rules.push({
_id: uid(),
userGroup,
usingGroup,
ratio: typeof ratio === 'number' ? ratio : 1,
});
}
}
return rules;
}
function nestRules(rules) {
const result = {};
rules.forEach(({ userGroup, usingGroup, ratio }) => {
if (!userGroup || !usingGroup) return;
if (!result[userGroup]) result[userGroup] = {};
result[userGroup][usingGroup] = ratio;
});
return result;
}
export function serializeGroupGroupRatio(rules) {
const nested = nestRules(rules);
return Object.keys(nested).length === 0
? ''
: JSON.stringify(nested, null, 2);
}
export default function GroupGroupRatioRules({
value,
groupNames = [],
onChange,
}) {
const { t } = useTranslation();
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
const emitChange = useCallback(
(newRules) => {
setRules(newRules);
onChange?.(serializeGroupGroupRatio(newRules));
},
[onChange],
);
const updateRule = useCallback(
(id, field, val) => {
const next = rules.map((r) =>
r._id === id ? { ...r, [field]: val } : r,
);
emitChange(next);
},
[rules, emitChange],
);
const addRule = useCallback(() => {
emitChange([
...rules,
{ _id: uid(), userGroup: '', usingGroup: '', ratio: 1 },
]);
}, [rules, emitChange]);
const removeRule = useCallback(
(id) => {
emitChange(rules.filter((r) => r._id !== id));
},
[rules, emitChange],
);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const columns = useMemo(
() => [
{
title: t('用户分组'),
dataIndex: 'userGroup',
key: 'userGroup',
width: 200,
render: (_, record) => (
<Select
size='small'
filter
value={record.userGroup || undefined}
placeholder={t('选择用户分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'userGroup', v)}
style={{ width: '100%' }}
allowCreate
position='bottomLeft'
/>
),
},
{
title: t('使用分组'),
dataIndex: 'usingGroup',
key: 'usingGroup',
width: 200,
render: (_, record) => (
<Select
size='small'
filter
value={record.usingGroup || undefined}
placeholder={t('选择使用分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'usingGroup', v)}
style={{ width: '100%' }}
allowCreate
position='bottomLeft'
/>
),
},
{
title: t('倍率'),
dataIndex: 'ratio',
key: 'ratio',
width: 140,
render: (_, record) => (
<InputNumber
size='small'
min={0}
step={0.1}
value={record.ratio}
style={{ width: '100%' }}
onChange={(v) => updateRule(record._id, 'ratio', v ?? 0)}
/>
),
},
{
title: '',
key: 'actions',
width: 50,
render: (_, record) => (
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => removeRule(record._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
),
},
],
[t, groupOptions, updateRule, removeRule],
);
return (
<div>
<CardTable
columns={columns}
dataSource={rules}
rowKey='_id'
hidePagination
size='small'
empty={
<Text type='tertiary'>
{t('暂无规则,点击下方按钮添加')}
</Text>
}
/>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
{t('添加规则')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,276 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Input,
Select,
Tag,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `gsu_${++_idCounter}`;
const OP_ADD = 'add';
const OP_REMOVE = 'remove';
const OP_APPEND = 'append';
function parsePrefix(rawKey) {
if (rawKey.startsWith('+:')) {
return { op: OP_ADD, groupName: rawKey.slice(2) };
}
if (rawKey.startsWith('-:')) {
return { op: OP_REMOVE, groupName: rawKey.slice(2) };
}
return { op: OP_APPEND, groupName: rawKey };
}
function toRawKey(op, groupName) {
if (op === OP_ADD) return `+:${groupName}`;
if (op === OP_REMOVE) return `-:${groupName}`;
return groupName;
}
function parseJSON(str) {
if (!str || !str.trim()) return {};
try {
return JSON.parse(str);
} catch {
return {};
}
}
function flattenRules(nested) {
const rules = [];
for (const [userGroup, inner] of Object.entries(nested)) {
if (typeof inner !== 'object' || inner === null) continue;
for (const [rawKey, desc] of Object.entries(inner)) {
const { op, groupName } = parsePrefix(rawKey);
rules.push({
_id: uid(),
userGroup,
op,
targetGroup: groupName,
description: op === OP_REMOVE ? 'remove' : (typeof desc === 'string' ? desc : ''),
});
}
}
return rules;
}
function nestRules(rules) {
const result = {};
rules.forEach(({ userGroup, op, targetGroup, description }) => {
if (!userGroup || !targetGroup) return;
if (!result[userGroup]) result[userGroup] = {};
const key = toRawKey(op, targetGroup);
result[userGroup][key] = description;
});
return result;
}
export function serializeGroupSpecialUsable(rules) {
const nested = nestRules(rules);
return Object.keys(nested).length === 0
? ''
: JSON.stringify(nested, null, 2);
}
const OP_TAG_MAP = {
[OP_ADD]: { color: 'green', label: '添加 (+:)' },
[OP_REMOVE]: { color: 'red', label: '移除 (-:)' },
[OP_APPEND]: { color: 'blue', label: '追加' },
};
export default function GroupSpecialUsableRules({
value,
groupNames = [],
onChange,
}) {
const { t } = useTranslation();
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
const emitChange = useCallback(
(newRules) => {
setRules(newRules);
onChange?.(serializeGroupSpecialUsable(newRules));
},
[onChange],
);
const updateRule = useCallback(
(id, field, val) => {
const next = rules.map((r) => {
if (r._id !== id) return r;
const updated = { ...r, [field]: val };
if (field === 'op' && val === OP_REMOVE) {
updated.description = 'remove';
} else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
if (updated.description === 'remove') updated.description = '';
}
return updated;
});
emitChange(next);
},
[rules, emitChange],
);
const addRule = useCallback(() => {
emitChange([
...rules,
{
_id: uid(),
userGroup: '',
op: OP_APPEND,
targetGroup: '',
description: '',
},
]);
}, [rules, emitChange]);
const removeRule = useCallback(
(id) => {
emitChange(rules.filter((r) => r._id !== id));
},
[rules, emitChange],
);
const groupOptions = useMemo(
() => groupNames.map((n) => ({ value: n, label: n })),
[groupNames],
);
const opOptions = useMemo(
() => [
{ value: OP_ADD, label: t('添加 (+:)') },
{ value: OP_REMOVE, label: t('移除 (-:)') },
{ value: OP_APPEND, label: t('追加') },
],
[t],
);
const columns = useMemo(
() => [
{
title: t('用户分组'),
dataIndex: 'userGroup',
key: 'userGroup',
width: 180,
render: (_, record) => (
<Select
size='small'
filter
value={record.userGroup || undefined}
placeholder={t('选择用户分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'userGroup', v)}
style={{ width: '100%' }}
allowCreate
position='bottomLeft'
/>
),
},
{
title: t('操作'),
dataIndex: 'op',
key: 'op',
width: 140,
render: (_, record) => (
<Select
size='small'
value={record.op}
optionList={opOptions}
onChange={(v) => updateRule(record._id, 'op', v)}
style={{ width: '100%' }}
renderSelectedItem={(optionNode) => {
const tagInfo = OP_TAG_MAP[optionNode.value] || {};
return (
<Tag size='small' color={tagInfo.color}>
{optionNode.label}
</Tag>
);
}}
/>
),
},
{
title: t('目标分组'),
dataIndex: 'targetGroup',
key: 'targetGroup',
width: 180,
render: (_, record) => (
<Input
size='small'
value={record.targetGroup}
placeholder={t('分组名称')}
onChange={(v) => updateRule(record._id, 'targetGroup', v)}
/>
),
},
{
title: t('描述'),
dataIndex: 'description',
key: 'description',
render: (_, record) =>
record.op === OP_REMOVE ? (
<Text type='tertiary' size='small'>-</Text>
) : (
<Input
size='small'
value={record.description}
placeholder={t('分组描述')}
onChange={(v) => updateRule(record._id, 'description', v)}
/>
),
},
{
title: '',
key: 'actions',
width: 50,
render: (_, record) => (
<Popconfirm
title={t('确认删除该规则?')}
onConfirm={() => removeRule(record._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
),
},
],
[t, groupOptions, opOptions, updateRule, removeRule],
);
return (
<div>
<CardTable
columns={columns}
dataSource={rules}
rowKey='_id'
hidePagination
size='small'
empty={
<Text type='tertiary'>
{t('暂无规则,点击下方按钮添加')}
</Text>
}
/>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRule}>
{t('添加规则')}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,242 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Input,
InputNumber,
Checkbox,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
let _idCounter = 0;
const uid = () => `gr_${++_idCounter}`;
function parseJSON(str, fallback) {
if (!str || !str.trim()) return fallback;
try {
return JSON.parse(str);
} catch {
return fallback;
}
}
function buildRows(groupRatioStr, userUsableGroupsStr) {
const ratioMap = parseJSON(groupRatioStr, {});
const usableMap = parseJSON(userUsableGroupsStr, {});
const allNames = new Set([
...Object.keys(ratioMap),
...Object.keys(usableMap),
]);
return Array.from(allNames).map((name) => ({
_id: uid(),
name,
ratio: ratioMap[name] ?? 1,
selectable: name in usableMap,
description: usableMap[name] ?? '',
}));
}
export function serializeGroupTable(rows) {
const groupRatio = {};
const userUsableGroups = {};
rows.forEach((row) => {
if (!row.name) return;
groupRatio[row.name] = row.ratio;
if (row.selectable) {
userUsableGroups[row.name] = row.description;
}
});
return {
GroupRatio: JSON.stringify(groupRatio, null, 2),
UserUsableGroups: JSON.stringify(userUsableGroups, null, 2),
};
}
export default function GroupTable({
groupRatio,
userUsableGroups,
onChange,
}) {
const { t } = useTranslation();
const [rows, setRows] = useState(() =>
buildRows(groupRatio, userUsableGroups),
);
const emitChange = useCallback(
(newRows) => {
setRows(newRows);
onChange?.(serializeGroupTable(newRows));
},
[onChange],
);
const updateRow = useCallback(
(id, field, value) => {
const next = rows.map((r) =>
r._id === id ? { ...r, [field]: value } : r,
);
emitChange(next);
},
[rows, emitChange],
);
const addRow = useCallback(() => {
const existingNames = new Set(rows.map((r) => r.name));
let counter = 1;
let newName = `group_${counter}`;
while (existingNames.has(newName)) {
counter++;
newName = `group_${counter}`;
}
emitChange([
...rows,
{
_id: uid(),
name: newName,
ratio: 1,
selectable: true,
description: '',
},
]);
}, [rows, emitChange]);
const removeRow = useCallback(
(id) => {
emitChange(rows.filter((r) => r._id !== id));
},
[rows, emitChange],
);
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
const duplicateNames = useMemo(() => {
const counts = {};
groupNames.forEach((n) => {
counts[n] = (counts[n] || 0) + 1;
});
return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
}, [groupNames]);
const columns = useMemo(
() => [
{
title: t('分组名称'),
dataIndex: 'name',
key: 'name',
width: 180,
render: (_, record) => (
<Input
size='small'
value={record.name}
status={duplicateNames.has(record.name) ? 'warning' : undefined}
onChange={(v) => updateRow(record._id, 'name', v)}
/>
),
},
{
title: t('倍率'),
dataIndex: 'ratio',
key: 'ratio',
width: 120,
render: (_, record) => (
<InputNumber
size='small'
min={0}
step={0.1}
value={record.ratio}
style={{ width: '100%' }}
onChange={(v) => updateRow(record._id, 'ratio', v ?? 0)}
/>
),
},
{
title: t('用户可选'),
dataIndex: 'selectable',
key: 'selectable',
width: 90,
align: 'center',
render: (_, record) => (
<Checkbox
checked={record.selectable}
onChange={(e) =>
updateRow(record._id, 'selectable', e.target.checked)
}
/>
),
},
{
title: t('描述'),
dataIndex: 'description',
key: 'description',
render: (_, record) =>
record.selectable ? (
<Input
size='small'
value={record.description}
placeholder={t('分组描述')}
onChange={(v) => updateRow(record._id, 'description', v)}
/>
) : (
<Text type='tertiary' size='small'>
-
</Text>
),
},
{
title: '',
key: 'actions',
width: 50,
render: (_, record) => (
<Popconfirm
title={t('确认删除该分组?')}
onConfirm={() => removeRow(record._id)}
position='left'
>
<Button
icon={<IconDelete />}
type='danger'
theme='borderless'
size='small'
/>
</Popconfirm>
),
},
],
[t, duplicateNames, updateRow, removeRow],
);
return (
<div>
<CardTable
columns={columns}
dataSource={rows}
rowKey='_id'
hidePagination
size='small'
empty={
<Text type='tertiary'>{t('暂无分组,点击下方按钮添加')}</Text>
}
/>
<div className='mt-3 flex justify-center'>
<Button icon={<IconPlus />} theme='outline' onClick={addRow}>
{t('添加分组')}
</Button>
</div>
{duplicateNames.size > 0 && (
<Text type='warning' size='small' className='mt-2 block'>
{t('存在重复的分组名称:')}{Array.from(duplicateNames).join(', ')}
</Text>
)}
</div>
);
}