feat(web): redesign group ratio rules with collapsible grouped layout

Rewrite GroupGroupRatioRules and GroupSpecialUsableRules to group rules
by user group in collapsible sections instead of a flat table. Default
collapsed to reduce visual clutter when many rules exist. Fix i18n
translations for ja, zh-TW with proper native text; add missing keys.
This commit is contained in:
CaIon
2026-04-08 17:09:19 +08:00
parent c734db34e8
commit 78e4cb3cad
8 changed files with 462 additions and 1141 deletions
@@ -1,14 +1,21 @@
import React, { useState, useCallback, useMemo } from 'react';
import {
Button,
Collapsible,
Input,
InputNumber,
Select,
Tag,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
import {
IconPlus,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
@@ -57,14 +64,106 @@ export function serializeGroupGroupRatio(rules) {
: 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) => {
@@ -76,21 +175,11 @@ export default function GroupGroupRatioRules({
const updateRule = useCallback(
(id, field, val) => {
const next = rules.map((r) =>
r._id === id ? { ...r, [field]: val } : r,
);
emitChange(next);
emitChange(rules.map((r) => (r._id === id ? { ...r, [field]: val } : r)));
},
[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));
@@ -98,107 +187,99 @@ export default function GroupGroupRatioRules({
[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 columns = useMemo(
() => [
{
title: t('用户分组'),
dataIndex: 'userGroup',
key: 'userGroup',
width: 200,
render: (_, record) => (
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
value={record.userGroup || undefined}
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'userGroup', v)}
style={{ width: '100%' }}
allowCreate
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
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],
);
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
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('添加规则')}
<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>
@@ -1,15 +1,38 @@
/*
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 } from '@douyinfe/semi-icons';
import {
IconPlus,
IconDelete,
IconChevronDown,
IconChevronUp,
} from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import CardTable from '../../../../components/common/ui/CardTable';
const { Text } = Typography;
@@ -21,12 +44,8 @@ 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) };
}
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 };
}
@@ -38,11 +57,7 @@ function toRawKey(op, groupName) {
function parseJSON(str) {
if (!str || !str.trim()) return {};
try {
return JSON.parse(str);
} catch {
return {};
}
try { return JSON.parse(str); } catch { return {}; }
}
function flattenRules(nested) {
@@ -68,17 +83,14 @@ function nestRules(rules) {
rules.forEach(({ userGroup, op, targetGroup, description }) => {
if (!userGroup || !targetGroup) return;
if (!result[userGroup]) result[userGroup] = {};
const key = toRawKey(op, targetGroup);
result[userGroup][key] = description;
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);
return Object.keys(nested).length === 0 ? '' : JSON.stringify(nested, null, 2);
}
const OP_TAG_MAP = {
@@ -87,14 +99,118 @@ const OP_TAG_MAP = {
[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) => {
@@ -106,41 +222,46 @@ export default function GroupSpecialUsableRules({
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);
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 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));
(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],
@@ -155,120 +276,74 @@ export default function GroupSpecialUsableRules({
[t],
);
const columns = useMemo(
() => [
{
title: t('用户分组'),
dataIndex: 'userGroup',
key: 'userGroup',
width: 180,
render: (_, record) => (
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
value={record.userGroup || undefined}
allowCreate
placeholder={t('选择用户分组')}
optionList={groupOptions}
onChange={(v) => updateRule(record._id, 'userGroup', v)}
style={{ width: '100%' }}
allowCreate
value={newGroupName || undefined}
onChange={setNewGroupName}
style={{ width: 200 }}
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],
);
<Button icon={<IconPlus />} theme='outline' onClick={addNewGroup}>
{t('添加分组规则')}
</Button>
</div>
</div>
);
}
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('添加规则')}
<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>