Merge remote-tracking branch 'origin/main' into nightly
# Conflicts: # web/src/helpers/render.jsx # web/src/hooks/usage-logs/useUsageLogsData.jsx # web/src/i18n/locales/en.json
This commit is contained in:
@@ -103,6 +103,7 @@ const RULES_JSON_PLACEHOLDER = `[
|
||||
},
|
||||
"skip_retry_on_failure": false,
|
||||
"include_using_group": true,
|
||||
"include_model_name": false,
|
||||
"include_rule_name": true
|
||||
}
|
||||
]`;
|
||||
@@ -191,6 +192,36 @@ const parseOptionalObjectJson = (jsonString, label) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildChannelAffinityRulePayload = ({
|
||||
values,
|
||||
isEdit,
|
||||
editingRuleId,
|
||||
rulesLength,
|
||||
modelRegex,
|
||||
pathRegex,
|
||||
keySources,
|
||||
userAgentInclude,
|
||||
paramOverrideTemplate,
|
||||
}) => ({
|
||||
id: isEdit ? editingRuleId : rulesLength,
|
||||
name: (values?.name || '').trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: pathRegex,
|
||||
key_sources: keySources,
|
||||
value_regex: (values?.value_regex || '').trim(),
|
||||
ttl_seconds: Number(values?.ttl_seconds || 0),
|
||||
include_using_group: !!values?.include_using_group,
|
||||
include_model_name: !!values?.include_model_name,
|
||||
include_rule_name: !!values?.include_rule_name,
|
||||
skip_retry_on_failure: !!values?.skip_retry_on_failure,
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramOverrideTemplate
|
||||
? { param_override_template: paramOverrideTemplate }
|
||||
: {}),
|
||||
});
|
||||
|
||||
export default function SettingsChannelAffinity(props) {
|
||||
const { t } = useTranslation();
|
||||
const { Text } = Typography;
|
||||
@@ -246,6 +277,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
ttl_seconds: Number(r.ttl_seconds || 0),
|
||||
skip_retry_on_failure: !!r.skip_retry_on_failure,
|
||||
include_using_group: r.include_using_group ?? true,
|
||||
include_model_name: !!r.include_model_name,
|
||||
include_rule_name: r.include_rule_name ?? true,
|
||||
param_override_template_json: r.param_override_template
|
||||
? stringifyPretty(r.param_override_template)
|
||||
@@ -454,14 +486,12 @@ export default function SettingsChannelAffinity(props) {
|
||||
const templates = [
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES.codexCli,
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES.claudeCli,
|
||||
].map(
|
||||
(tpl) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...baseTemplate, name };
|
||||
},
|
||||
);
|
||||
].map((tpl) => {
|
||||
const baseTemplate = cloneChannelAffinityTemplate(tpl);
|
||||
const name = makeUniqueName(existingNames, tpl.name);
|
||||
existingNames.add(name);
|
||||
return { ...baseTemplate, name };
|
||||
});
|
||||
|
||||
const next = [...(rules || []), ...templates].map((r, idx) => ({
|
||||
...(r || {}),
|
||||
@@ -540,11 +570,11 @@ export default function SettingsChannelAffinity(props) {
|
||||
render: (v) => <Text>{Number(v || 0) || '-'}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('失败后不重试'),
|
||||
title: t('失败后是否重试'),
|
||||
dataIndex: 'skip_retry_on_failure',
|
||||
render: (value) => (
|
||||
<Tag color={value ? 'orange' : 'grey'} style={{ marginRight: 4 }}>
|
||||
{value ? t('是') : t('否')}
|
||||
<Tag color={value ? 'orange' : 'green'} style={{ marginRight: 4 }}>
|
||||
{value ? t('不重试') : t('重试')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -581,8 +611,9 @@ export default function SettingsChannelAffinity(props) {
|
||||
title: t('作用域'),
|
||||
render: (_, record) => {
|
||||
const tags = [];
|
||||
if (record?.include_using_group) tags.push('分组');
|
||||
if (record?.include_rule_name) tags.push('规则');
|
||||
if (record?.include_using_group) tags.push(t('分组'));
|
||||
if (record?.include_model_name) tags.push(t('模型'));
|
||||
if (record?.include_rule_name) tags.push(t('规则'));
|
||||
if (tags.length === 0) return '-';
|
||||
return tags.map((x) => (
|
||||
<Tag key={x} style={{ marginRight: 4 }}>
|
||||
@@ -650,6 +681,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
ttl_seconds: 0,
|
||||
skip_retry_on_failure: false,
|
||||
include_using_group: true,
|
||||
include_model_name: false,
|
||||
include_rule_name: true,
|
||||
};
|
||||
setEditingRule(nextRule);
|
||||
@@ -712,26 +744,17 @@ export default function SettingsChannelAffinity(props) {
|
||||
return showError(t(paramTemplateValidation.message));
|
||||
}
|
||||
|
||||
const rulePayload = {
|
||||
id: isEdit ? editingRule.id : rules.length,
|
||||
name: (values.name || '').trim(),
|
||||
model_regex: modelRegex,
|
||||
path_regex: normalizeStringList(values.path_regex_text),
|
||||
key_sources: keySourcesValidation.value,
|
||||
value_regex: (values.value_regex || '').trim(),
|
||||
ttl_seconds: Number(values.ttl_seconds || 0),
|
||||
include_using_group: !!values.include_using_group,
|
||||
include_rule_name: !!values.include_rule_name,
|
||||
...(values.skip_retry_on_failure
|
||||
? { skip_retry_on_failure: true }
|
||||
: {}),
|
||||
...(userAgentInclude.length > 0
|
||||
? { user_agent_include: userAgentInclude }
|
||||
: {}),
|
||||
...(paramTemplateValidation.value
|
||||
? { param_override_template: paramTemplateValidation.value }
|
||||
: {}),
|
||||
};
|
||||
const rulePayload = buildChannelAffinityRulePayload({
|
||||
values,
|
||||
isEdit,
|
||||
editingRuleId: editingRule?.id,
|
||||
rulesLength: rules.length,
|
||||
modelRegex,
|
||||
pathRegex: normalizeStringList(values.path_regex_text),
|
||||
keySources: keySourcesValidation.value,
|
||||
userAgentInclude,
|
||||
paramOverrideTemplate: paramTemplateValidation.value,
|
||||
});
|
||||
|
||||
if (!rulePayload.name) return showError(t('名称不能为空'));
|
||||
|
||||
@@ -1251,7 +1274,7 @@ export default function SettingsChannelAffinity(props) {
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_using_group'
|
||||
label={t('作用域:包含分组')}
|
||||
@@ -1262,7 +1285,16 @@ export default function SettingsChannelAffinity(props) {
|
||||
)}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_model_name'
|
||||
label={t('作用域:包含模型名称')}
|
||||
/>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('开启后,模型名称会参与 cache key(不同模型隔离)。')}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Switch
|
||||
field='include_rule_name'
|
||||
label={t('作用域:包含规则名称')}
|
||||
|
||||
@@ -26,9 +26,8 @@ import {
|
||||
Row,
|
||||
Spin,
|
||||
Modal,
|
||||
Select,
|
||||
InputGroup,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
compareObjects,
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function GeneralSettings(props) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -126,6 +127,77 @@ export default function GeneralSettings(props) {
|
||||
}
|
||||
};
|
||||
|
||||
const showTokensOption = useMemo(() => {
|
||||
const initialType = props.options?.['general_setting.quota_display_type'];
|
||||
const initialQuotaPerUnit = parseFloat(props.options?.QuotaPerUnit);
|
||||
const legacyTokensMode =
|
||||
initialType === undefined &&
|
||||
props.options?.DisplayInCurrencyEnabled !== undefined &&
|
||||
!props.options.DisplayInCurrencyEnabled;
|
||||
return (
|
||||
initialType === 'TOKENS' ||
|
||||
legacyTokensMode ||
|
||||
(!isNaN(initialQuotaPerUnit) && initialQuotaPerUnit !== 500000)
|
||||
);
|
||||
}, [props.options]);
|
||||
|
||||
const quotaDisplayType = inputs['general_setting.quota_display_type'];
|
||||
|
||||
const quotaDisplayTypeDesc = useMemo(() => {
|
||||
const descMap = {
|
||||
USD: t('站点所有额度将以美元 ($) 显示'),
|
||||
CNY: t('站点所有额度将按汇率换算为人民币 (¥) 显示'),
|
||||
TOKENS: t('站点所有额度将以原始 Token 数显示,不做货币换算'),
|
||||
CUSTOM: t('站点所有额度将按汇率换算为自定义货币显示'),
|
||||
};
|
||||
return descMap[quotaDisplayType] || '';
|
||||
}, [quotaDisplayType, t]);
|
||||
|
||||
const rateLabel = useMemo(() => {
|
||||
if (quotaDisplayType === 'CNY') return t('汇率');
|
||||
if (quotaDisplayType === 'TOKENS') return t('每美元对应 Token 数');
|
||||
if (quotaDisplayType === 'CUSTOM') return t('汇率');
|
||||
return '';
|
||||
}, [quotaDisplayType, t]);
|
||||
|
||||
const rateSuffix = useMemo(() => {
|
||||
if (quotaDisplayType === 'CNY') return 'CNY (¥)';
|
||||
if (quotaDisplayType === 'TOKENS') return 'Tokens';
|
||||
if (quotaDisplayType === 'CUSTOM')
|
||||
return inputs['general_setting.custom_currency_symbol'] || '¤';
|
||||
return '';
|
||||
}, [quotaDisplayType, inputs]);
|
||||
|
||||
const rateExtraText = useMemo(() => {
|
||||
if (quotaDisplayType === 'CNY')
|
||||
return t(
|
||||
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为人民币,不影响内部计费',
|
||||
);
|
||||
if (quotaDisplayType === 'TOKENS')
|
||||
return t(
|
||||
'系统内部计费精度,默认 500000,修改可能导致计费异常,请谨慎操作',
|
||||
);
|
||||
if (quotaDisplayType === 'CUSTOM')
|
||||
return t(
|
||||
'系统内部以美元 (USD) 为基准计价。用户余额、充值金额、模型定价、用量日志等所有金额显示均按此汇率换算为自定义货币,不影响内部计费',
|
||||
);
|
||||
return '';
|
||||
}, [quotaDisplayType, t]);
|
||||
|
||||
const previewText = useMemo(() => {
|
||||
if (quotaDisplayType === 'USD') return '$1.00';
|
||||
const rate = parseFloat(combinedRate);
|
||||
if (!rate || isNaN(rate)) return t('请输入汇率');
|
||||
if (quotaDisplayType === 'CNY') return `$1.00 → ¥${rate.toFixed(2)}`;
|
||||
if (quotaDisplayType === 'TOKENS')
|
||||
return `$1.00 → ${Number(rate).toLocaleString()} Tokens`;
|
||||
if (quotaDisplayType === 'CUSTOM') {
|
||||
const symbol = inputs['general_setting.custom_currency_symbol'] || '¤';
|
||||
return `$1.00 → ${symbol}${rate.toFixed(2)}`;
|
||||
}
|
||||
return '';
|
||||
}, [quotaDisplayType, combinedRate, inputs, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentInputs = {};
|
||||
for (let key in props.options) {
|
||||
@@ -202,48 +274,79 @@ export default function GeneralSettings(props) {
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Slot label={t('站点额度展示类型及汇率')}>
|
||||
<InputGroup style={{ width: '100%' }}>
|
||||
<Form.Select
|
||||
field='general_setting.quota_display_type'
|
||||
label={t('额度展示类型')}
|
||||
extraText={quotaDisplayTypeDesc}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.quota_display_type',
|
||||
)}
|
||||
>
|
||||
<Form.Select.Option value='USD'>
|
||||
USD ($)
|
||||
</Form.Select.Option>
|
||||
<Form.Select.Option value='CNY'>
|
||||
CNY (¥)
|
||||
</Form.Select.Option>
|
||||
{showTokensOption && (
|
||||
<Form.Select.Option value='TOKENS'>
|
||||
Tokens
|
||||
</Form.Select.Option>
|
||||
)}
|
||||
<Form.Select.Option value='CUSTOM'>
|
||||
{t('自定义货币')}
|
||||
</Form.Select.Option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
{quotaDisplayType !== 'USD' && (
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Slot label={rateLabel}>
|
||||
<Input
|
||||
prefix={'1 USD = '}
|
||||
style={{ width: '50%' }}
|
||||
prefix='1 USD = '
|
||||
suffix={rateSuffix}
|
||||
value={combinedRate}
|
||||
onChange={onCombinedRateChange}
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] === 'USD'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
value={inputs['general_setting.quota_display_type']}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.quota_display_type',
|
||||
)}
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
style={{ marginTop: 4, display: 'block' }}
|
||||
>
|
||||
<Select.Option value='USD'>USD ($)</Select.Option>
|
||||
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
||||
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
||||
<Select.Option value='CUSTOM'>
|
||||
{t('自定义货币')}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</InputGroup>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
{rateExtraText}
|
||||
</Text>
|
||||
</Form.Slot>
|
||||
</Col>
|
||||
)}
|
||||
<Col
|
||||
xs={24}
|
||||
sm={12}
|
||||
md={8}
|
||||
lg={8}
|
||||
xl={8}
|
||||
style={
|
||||
quotaDisplayType !== 'CUSTOM'
|
||||
? { display: 'none' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Form.Input
|
||||
field={'general_setting.custom_currency_symbol'}
|
||||
field='general_setting.custom_currency_symbol'
|
||||
label={t('自定义货币符号')}
|
||||
extraText={t(
|
||||
'自定义货币符号将显示在所有额度数值前,例如 €1.50',
|
||||
)}
|
||||
placeholder={t('例如 €, £, Rp, ₩, ₹...')}
|
||||
onChange={handleFieldChange(
|
||||
'general_setting.custom_currency_symbol',
|
||||
)}
|
||||
showClear
|
||||
disabled={
|
||||
inputs['general_setting.quota_display_type'] !== 'CUSTOM'
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('预览效果')}:{previewText}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
|
||||
@@ -356,7 +356,6 @@ export default function SettingsPerformance(props) {
|
||||
label={t('CPU 阈值 (%)')}
|
||||
extraText={t('CPU 使用率超过此值时拒绝请求')}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleFieldChange(
|
||||
'performance_setting.monitor_cpu_threshold',
|
||||
)}
|
||||
|
||||
@@ -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,287 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function GroupSection({ groupName, items, groupOptions, onUpdate, onRemove, onAdd, t }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
|
||||
<Text strong>{groupName}</Text>
|
||||
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => onAdd(groupName)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组的所有规则?')}
|
||||
onConfirm={() => items.forEach((item) => onRemove(item._id))}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{items.map((rule) => (
|
||||
<div
|
||||
key={rule._id}
|
||||
className='flex items-center gap-2'
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
value={rule.usingGroup || undefined}
|
||||
placeholder={t('选择使用分组')}
|
||||
optionList={groupOptions}
|
||||
onChange={(v) => onUpdate(rule._id, 'usingGroup', v)}
|
||||
style={{ flex: 1 }}
|
||||
allowCreate
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<InputNumber
|
||||
size='small'
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={rule.ratio}
|
||||
style={{ width: 100 }}
|
||||
onChange={(v) => onUpdate(rule._id, 'ratio', v ?? 0)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => onRemove(rule._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupGroupRatioRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
setRules(newRules);
|
||||
onChange?.(serializeGroupGroupRatio(newRules));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => {
|
||||
emitChange(rules.filter((r) => r._id !== id));
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName) => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: groupName, usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addNewGroup = useCallback(() => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: name, usingGroup: '', ratio: 1 },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
}, [rules, emitChange, newGroupName]);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() => groupNames.map((n) => ({ value: n, label: n })),
|
||||
[groupNames],
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rules.forEach((r) => {
|
||||
if (!r.userGroup) return;
|
||||
if (!map[r.userGroup]) {
|
||||
map[r.userGroup] = [];
|
||||
order.push(r.userGroup);
|
||||
}
|
||||
map[r.userGroup].push(r);
|
||||
});
|
||||
return order.map((name) => ({ name, items: map[name] }));
|
||||
}, [rules]);
|
||||
|
||||
if (grouped.length === 0 && rules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{grouped.map((group) => (
|
||||
<GroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
groupOptions={groupOptions}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className='mt-3 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
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, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Typography,
|
||||
Popconfirm,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconPlus,
|
||||
IconDelete,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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] = {};
|
||||
result[userGroup][toRawKey(op, targetGroup)] = 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: '追加' },
|
||||
};
|
||||
|
||||
function UsableGroupSection({ groupName, items, opOptions, onUpdate, onRemove, onAdd, t }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--semi-color-fill-0)',
|
||||
}}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
{open ? <IconChevronUp size='small' /> : <IconChevronDown size='small' />}
|
||||
<Text strong>{groupName}</Text>
|
||||
<Tag size='small' color='blue'>{items.length} {t('条规则')}</Tag>
|
||||
</div>
|
||||
<div className='flex items-center gap-1' onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
onClick={() => onAdd(groupName)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('确认删除该分组的所有规则?')}
|
||||
onConfirm={() => items.forEach((item) => onRemove(item._id))}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible isOpen={open} keepDOM>
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{items.map((rule) => (
|
||||
<div
|
||||
key={rule._id}
|
||||
className='flex items-center gap-2'
|
||||
style={{ marginBottom: 6 }}
|
||||
>
|
||||
<Select
|
||||
size='small'
|
||||
value={rule.op}
|
||||
optionList={opOptions}
|
||||
onChange={(v) => onUpdate(rule._id, 'op', v)}
|
||||
style={{ width: 120 }}
|
||||
renderSelectedItem={(optionNode) => {
|
||||
const info = OP_TAG_MAP[optionNode.value] || {};
|
||||
return <Tag size='small' color={info.color}>{optionNode.label}</Tag>;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
size='small'
|
||||
value={rule.targetGroup}
|
||||
placeholder={t('分组名称')}
|
||||
onChange={(v) => onUpdate(rule._id, 'targetGroup', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{rule.op !== OP_REMOVE ? (
|
||||
<Input
|
||||
size='small'
|
||||
value={rule.description}
|
||||
placeholder={t('分组描述')}
|
||||
onChange={(v) => onUpdate(rule._id, 'description', v)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type='tertiary' size='small'>-</Text>
|
||||
</div>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('确认删除该规则?')}
|
||||
onConfirm={() => onRemove(rule._id)}
|
||||
position='left'
|
||||
>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
size='small'
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupSpecialUsableRules({
|
||||
value,
|
||||
groupNames = [],
|
||||
onChange,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
|
||||
const emitChange = useCallback(
|
||||
(newRules) => {
|
||||
setRules(newRules);
|
||||
onChange?.(serializeGroupSpecialUsable(newRules));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id, field, val) => {
|
||||
emitChange(
|
||||
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;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(id) => emitChange(rules.filter((r) => r._id !== id)),
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addRuleToGroup = useCallback(
|
||||
(groupName) => {
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: groupName, op: OP_APPEND, targetGroup: '', description: '' },
|
||||
]);
|
||||
},
|
||||
[rules, emitChange],
|
||||
);
|
||||
|
||||
const addNewGroup = useCallback(() => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
emitChange([
|
||||
...rules,
|
||||
{ _id: uid(), userGroup: name, op: OP_APPEND, targetGroup: '', description: '' },
|
||||
]);
|
||||
setNewGroupName('');
|
||||
}, [rules, emitChange, newGroupName]);
|
||||
|
||||
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 grouped = useMemo(() => {
|
||||
const map = {};
|
||||
const order = [];
|
||||
rules.forEach((r) => {
|
||||
if (!r.userGroup) return;
|
||||
if (!map[r.userGroup]) {
|
||||
map[r.userGroup] = [];
|
||||
order.push(r.userGroup);
|
||||
}
|
||||
map[r.userGroup].push(r);
|
||||
});
|
||||
return order.map((name) => ({ name, items: map[name] }));
|
||||
}, [rules]);
|
||||
|
||||
if (grouped.length === 0 && rules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<Text type='tertiary' className='block text-center py-4'>
|
||||
{t('暂无规则,点击下方按钮添加')}
|
||||
</Text>
|
||||
<div className='mt-2 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{t('添加分组规则')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{grouped.map((group) => (
|
||||
<UsableGroupSection
|
||||
key={group.name}
|
||||
groupName={group.name}
|
||||
items={group.items}
|
||||
opOptions={opOptions}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
onAdd={addRuleToGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<div className='mt-3 flex justify-center gap-2'>
|
||||
<Select
|
||||
size='small'
|
||||
filter
|
||||
allowCreate
|
||||
placeholder={t('选择用户分组')}
|
||||
optionList={groupOptions}
|
||||
value={newGroupName || undefined}
|
||||
onChange={setNewGroupName}
|
||||
style={{ width: 200 }}
|
||||
position='bottomLeft'
|
||||
/>
|
||||
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user