Commit e96ca775 by Seefs Committed by GitHub

Merge branch 'main' into feat/error-boundary

parents 559c98f2 1ad25576
......@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
unknown++
continue
}
if rule.IncludeUsingGroup {
if rule.IncludeModelName {
if len(parts) < 3 {
unknown++
continue
}
}
if rule.IncludeUsingGroup {
minParts := 3
if rule.IncludeModelName {
minParts = 4
}
if len(parts) < minParts {
unknown++
continue
}
}
byRuleName[ruleName]++
}
......@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 3)
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
parts = append(parts, modelName)
}
if rule.IncludeUsingGroup && usingGroup != "" {
parts = append(parts, usingGroup)
}
......@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
......
......@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
......
......@@ -23,6 +23,7 @@ type ChannelAffinityRule struct {
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"`
}
......
......@@ -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%' }}
/>
......
......@@ -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,
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -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
}
]`;
......@@ -246,6 +247,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)
......@@ -581,8 +583,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 +653,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);
......@@ -721,6 +725,7 @@ export default function SettingsChannelAffinity(props) {
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,
...(values.skip_retry_on_failure
? { skip_retry_on_failure: true }
......@@ -1251,7 +1256,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 +1267,18 @@ 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('作用域:包含规则名称')}
......
/*
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment