Просмотр исходного кода

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

- Replaced ModelRatioSettings with ModelPricingCombined in the RatioSetting component.
- Updated tab structure to prioritize pricing settings over model settings.
- Removed unused imports for ModelRatioSettings and ModelSettingsVisualEditor.
CaIon 1 месяц назад
Родитель
Сommit
dc83c4af31

+ 4 - 9
web/src/components/settings/RatioSetting.jsx

@@ -21,9 +21,8 @@ import React, { useEffect, useState } from 'react';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
+import ModelPricingCombined from '../../pages/Setting/Ratio/ModelPricingCombined';
 import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
 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 ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
 import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
 import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
 
 
@@ -95,18 +94,14 @@ const RatioSetting = () => {
 
 
   return (
   return (
     <Spin spinning={loading} size='large'>
     <Spin spinning={loading} size='large'>
-      {/* 模型倍率设置以及价格编辑器 */}
       <Card style={{ marginTop: '10px' }}>
       <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>
           <Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
           <Tabs.TabPane tab={t('分组相关设置')} itemKey='group'>
             <GroupRatioSettings options={inputs} refresh={onRefresh} />
             <GroupRatioSettings options={inputs} refresh={onRefresh} />
           </Tabs.TabPane>
           </Tabs.TabPane>
-          <Tabs.TabPane tab={t('价格设置')} itemKey='visual'>
-            <ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
-          </Tabs.TabPane>
           <Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
           <Tabs.TabPane tab={t('未设置价格模型')} itemKey='unset_models'>
             <ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
             <ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
           </Tabs.TabPane>
           </Tabs.TabPane>

+ 14 - 3
web/src/components/table/tokens/TokensColumnDefs.jsx

@@ -88,7 +88,7 @@ const renderStatus = (text, record, t) => {
 };
 };
 
 
 // Render group column
 // Render group column
-const renderGroupColumn = (text, record, t) => {
+const renderGroupColumn = (text, record, t, groupRatios = {}) => {
   if (text === 'auto') {
   if (text === 'auto') {
     return (
     return (
       <Tooltip
       <Tooltip
@@ -104,7 +104,17 @@ const renderGroupColumn = (text, record, t) => {
       </Tooltip>
       </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
 // Render token key column with show/hide and copy functionality
@@ -469,6 +479,7 @@ export const getTokensColumns = ({
   setEditingToken,
   setEditingToken,
   setShowEdit,
   setShowEdit,
   refresh,
   refresh,
+  groupRatios = {},
 }) => {
 }) => {
   return [
   return [
     {
     {
@@ -490,7 +501,7 @@ export const getTokensColumns = ({
       title: t('分组'),
       title: t('分组'),
       dataIndex: 'group',
       dataIndex: 'group',
       key: 'group',
       key: 'group',
-      render: (text, record) => renderGroupColumn(text, record, t),
+      render: (text, record) => renderGroupColumn(text, record, t, groupRatios),
     },
     },
     {
     {
       title: t('密钥'),
       title: t('密钥'),

+ 3 - 0
web/src/components/table/tokens/TokensTable.jsx

@@ -49,6 +49,7 @@ const TokensTable = (tokensData) => {
     setEditingToken,
     setEditingToken,
     setShowEdit,
     setShowEdit,
     refresh,
     refresh,
+    groupRatios,
     t,
     t,
   } = tokensData;
   } = tokensData;
 
 
@@ -67,6 +68,7 @@ const TokensTable = (tokensData) => {
       setEditingToken,
       setEditingToken,
       setShowEdit,
       setShowEdit,
       refresh,
       refresh,
+      groupRatios,
     });
     });
   }, [
   }, [
     t,
     t,
@@ -81,6 +83,7 @@ const TokensTable = (tokensData) => {
     setEditingToken,
     setEditingToken,
     setShowEdit,
     setShowEdit,
     refresh,
     refresh,
+    groupRatios,
   ]);
   ]);
 
 
   // Handle compact mode by removing fixed positioning
   // Handle compact mode by removing fixed positioning

+ 8 - 0
web/src/components/table/tokens/modals/EditTokenModal.jsx

@@ -366,6 +366,14 @@ const EditTokenModal = (props) => {
                         placeholder={t('令牌分组,默认为用户的分组')}
                         placeholder={t('令牌分组,默认为用户的分组')}
                         optionList={groups}
                         optionList={groups}
                         renderOptionItem={renderGroupOption}
                         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
                         showClear
                         style={{ width: '100%' }}
                         style={{ width: '100%' }}
                       />
                       />

+ 13 - 0
web/src/hooks/tokens/useTokensData.jsx

@@ -42,6 +42,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
   // Basic state
   // Basic state
   const [tokens, setTokens] = useState([]);
   const [tokens, setTokens] = useState([]);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
+  const [groupRatios, setGroupRatios] = useState({});
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
   const [tokenCount, setTokenCount] = useState(0);
   const [tokenCount, setTokenCount] = useState(0);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
   const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
@@ -437,6 +438,17 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
       .catch((reason) => {
       .catch((reason) => {
         showError(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]);
   }, [pageSize]);
 
 
   return {
   return {
@@ -447,6 +459,7 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
     tokenCount,
     tokenCount,
     pageSize,
     pageSize,
     searching,
     searching,
+    groupRatios,
 
 
     // Selection state
     // Selection state
     selectedKeys,
     selectedKeys,

Разница между файлами не показана из-за своего большого размера
+ 205 - 106
web/src/i18n/locales/en.json


Разница между файлами не показана из-за своего большого размера
+ 205 - 114
web/src/i18n/locales/fr.json


Разница между файлами не показана из-за своего большого размера
+ 207 - 110
web/src/i18n/locales/ja.json


Разница между файлами не показана из-за своего большого размера
+ 205 - 113
web/src/i18n/locales/ru.json


Разница между файлами не показана из-за своего большого размера
+ 203 - 103
web/src/i18n/locales/vi.json


Разница между файлами не показана из-за своего большого размера
+ 258 - 84
web/src/i18n/locales/zh-TW.json


+ 570 - 83
web/src/pages/Setting/Ratio/GroupRatioSettings.jsx

@@ -17,8 +17,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 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 {
 import {
   compareObjects,
   compareObjects,
   API,
   API,
@@ -28,10 +43,37 @@ import {
   verifyJSON,
   verifyJSON,
 } from '../../../helpers';
 } from '../../../helpers';
 import { useTranslation } from 'react-i18next';
 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) {
 export default function GroupRatioSettings(props) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
+  const [editMode, setEditMode] = useState('visual');
+  const [showGuide, setShowGuide] = useState(false);
+
   const [inputs, setInputs] = useState({
   const [inputs, setInputs] = useState({
     GroupRatio: '',
     GroupRatio: '',
     UserUsableGroups: '',
     UserUsableGroups: '',
@@ -42,80 +84,189 @@ export default function GroupRatioSettings(props) {
   });
   });
   const refForm = useRef();
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(inputs);
   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() {
   async function onSubmit() {
-    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('保存失败'),
-                );
-              }
+    if (editMode === 'manual') {
+      try {
+        await refForm.current.validate();
+      } catch {
+        showError(t('请检查输入'));
+        return;
+      }
+    }
 
 
-              for (let i = 0; i < res.length; i++) {
-                if (!res[i].data.success) {
-                  return showError(res[i].data.message);
-                }
-              }
+    const updateArray = compareObjects(inputs, inputsRow);
+    if (!updateArray.length) {
+      return showWarning(t('你似乎并没有修改什么'));
+    }
 
 
-              showSuccess(t('保存成功'));
-              props.refresh();
-            })
-            .catch((error) => {
-              console.error('Unexpected error:', error);
-              showError(t('保存失败,请重试'));
-            })
-            .finally(() => {
-              setLoading(false);
-            });
-        })
-        .catch(() => {
-          showError(t('请检查输入'));
-        });
+    const requestQueue = updateArray.map((item) => {
+      const value =
+        typeof inputs[item.key] === 'boolean'
+          ? String(inputs[item.key])
+          : inputs[item.key];
+      return API.put('/api/option/', { key: item.key, value });
+    });
+
+    setLoading(true);
+    try {
+      const res = await Promise.all(requestQueue);
+      if (res.includes(undefined)) {
+        return showError(
+          requestQueue.length > 1
+            ? t('部分保存失败,请重试')
+            : t('保存失败'),
+        );
+      }
+      for (let i = 0; i < res.length; i++) {
+        if (!res[i].data.success) {
+          return showError(res[i].data.message);
+        }
+      }
+      showSuccess(t('保存成功'));
+      props.refresh();
     } catch (error) {
     } catch (error) {
-      showError(t('请检查输入'));
-      console.error(error);
+      console.error('Unexpected error:', error);
+      showError(t('保存失败,请重试'));
+    } finally {
+      setLoading(false);
     }
     }
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {
     const currentInputs = {};
     const currentInputs = {};
     for (let key in props.options) {
     for (let key in props.options) {
-      if (Object.keys(inputs).includes(key)) {
+      if (OPTION_KEYS.includes(key)) {
         currentInputs[key] = props.options[key];
         currentInputs[key] = props.options[key];
       }
       }
     }
     }
     setInputs(currentInputs);
     setInputs(currentInputs);
     setInputsRow(structuredClone(currentInputs));
     setInputsRow(structuredClone(currentInputs));
-    refForm.current.setValues(currentInputs);
+    dataVersionRef.current += 1;
+    if (refForm.current) {
+      refForm.current.setValues(currentInputs);
+    }
   }, [props.options]);
   }, [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}>
         <Row gutter={16}>
           <Col xs={24} sm={16}>
           <Col xs={24} sm={16}>
             <Form.TextArea
             <Form.TextArea
@@ -134,7 +285,9 @@ export default function GroupRatioSettings(props) {
                   message: t('不是合法的 JSON 字符串'),
                   message: t('不是合法的 JSON 字符串'),
                 },
                 },
               ]}
               ]}
-              onChange={(value) => setInputs({ ...inputs, GroupRatio: value })}
+              onChange={(value) =>
+                setInputs((prev) => ({ ...prev, GroupRatio: value }))
+              }
             />
             />
           </Col>
           </Col>
         </Row>
         </Row>
@@ -142,7 +295,9 @@ export default function GroupRatioSettings(props) {
           <Col xs={24} sm={16}>
           <Col xs={24} sm={16}>
             <Form.TextArea
             <Form.TextArea
               label={t('用户可选分组')}
               label={t('用户可选分组')}
-              placeholder={t('为一个 JSON 文本,键为分组名称,值为分组描述')}
+              placeholder={t(
+                '为一个 JSON 文本,键为分组名称,值为分组描述',
+              )}
               extraText={t(
               extraText={t(
                 '用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
                 '用户新建令牌时可选的分组,格式为 JSON 字符串,例如:{"vip": "VIP 用户", "test": "测试"},表示用户可以选择 vip 分组和 test 分组',
               )}
               )}
@@ -157,7 +312,7 @@ export default function GroupRatioSettings(props) {
                 },
                 },
               ]}
               ]}
               onChange={(value) =>
               onChange={(value) =>
-                setInputs({ ...inputs, UserUsableGroups: value })
+                setInputs((prev) => ({ ...prev, UserUsableGroups: value }))
               }
               }
             />
             />
           </Col>
           </Col>
@@ -181,7 +336,7 @@ export default function GroupRatioSettings(props) {
                 },
                 },
               ]}
               ]}
               onChange={(value) =>
               onChange={(value) =>
-                setInputs({ ...inputs, GroupGroupRatio: value })
+                setInputs((prev) => ({ ...prev, GroupGroupRatio: value }))
               }
               }
             />
             />
           </Col>
           </Col>
@@ -205,10 +360,10 @@ export default function GroupRatioSettings(props) {
                 },
                 },
               ]}
               ]}
               onChange={(value) =>
               onChange={(value) =>
-                setInputs({
-                  ...inputs,
+                setInputs((prev) => ({
+                  ...prev,
                   'group_ratio_setting.group_special_usable_group': value,
                   'group_ratio_setting.group_special_usable_group': value,
-                })
+                }))
               }
               }
             />
             />
           </Col>
           </Col>
@@ -225,29 +380,23 @@ export default function GroupRatioSettings(props) {
               rules={[
               rules={[
                 {
                 {
                   validator: (rule, value) => {
                   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 {
                     try {
                       const parsed = JSON.parse(value);
                       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');
                       return parsed.every((item) => typeof item === 'string');
-                    } catch (error) {
+                    } catch {
                       return false;
                       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>
           </Col>
         </Row>
         </Row>
@@ -259,13 +408,351 @@ export default function GroupRatioSettings(props) {
               )}
               )}
               field={'DefaultUseAutoGroup'}
               field={'DefaultUseAutoGroup'}
               onChange={(value) =>
               onChange={(value) =>
-                setInputs({ ...inputs, DefaultUseAutoGroup: value })
+                setInputs((prev) => ({
+                  ...prev,
+                  DefaultUseAutoGroup: value,
+                }))
               }
               }
             />
             />
           </Col>
           </Col>
         </Row>
         </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>
     </Spin>
   );
   );
 }
 }

+ 50 - 0
web/src/pages/Setting/Ratio/ModelPricingCombined.jsx

@@ -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>
+  );
+}

+ 169 - 0
web/src/pages/Setting/Ratio/components/AutoGroupList.jsx

@@ -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>
+  );
+}

+ 206 - 0
web/src/pages/Setting/Ratio/components/GroupGroupRatioRules.jsx

@@ -0,0 +1,206 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import {
+  Button,
+  InputNumber,
+  Select,
+  Typography,
+  Popconfirm,
+} from '@douyinfe/semi-ui';
+import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import CardTable from '../../../../components/common/ui/CardTable';
+
+const { Text } = Typography;
+
+let _idCounter = 0;
+const uid = () => `ggr_${++_idCounter}`;
+
+function parseJSON(str) {
+  if (!str || !str.trim()) return {};
+  try {
+    return JSON.parse(str);
+  } catch {
+    return {};
+  }
+}
+
+function flattenRules(nested) {
+  const rules = [];
+  for (const [userGroup, inner] of Object.entries(nested)) {
+    if (typeof inner !== 'object' || inner === null) continue;
+    for (const [usingGroup, ratio] of Object.entries(inner)) {
+      rules.push({
+        _id: uid(),
+        userGroup,
+        usingGroup,
+        ratio: typeof ratio === 'number' ? ratio : 1,
+      });
+    }
+  }
+  return rules;
+}
+
+function nestRules(rules) {
+  const result = {};
+  rules.forEach(({ userGroup, usingGroup, ratio }) => {
+    if (!userGroup || !usingGroup) return;
+    if (!result[userGroup]) result[userGroup] = {};
+    result[userGroup][usingGroup] = ratio;
+  });
+  return result;
+}
+
+export function serializeGroupGroupRatio(rules) {
+  const nested = nestRules(rules);
+  return Object.keys(nested).length === 0
+    ? ''
+    : JSON.stringify(nested, null, 2);
+}
+
+export default function GroupGroupRatioRules({
+  value,
+  groupNames = [],
+  onChange,
+}) {
+  const { t } = useTranslation();
+
+  const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
+
+  const emitChange = useCallback(
+    (newRules) => {
+      setRules(newRules);
+      onChange?.(serializeGroupGroupRatio(newRules));
+    },
+    [onChange],
+  );
+
+  const updateRule = useCallback(
+    (id, field, val) => {
+      const next = rules.map((r) =>
+        r._id === id ? { ...r, [field]: val } : r,
+      );
+      emitChange(next);
+    },
+    [rules, emitChange],
+  );
+
+  const addRule = useCallback(() => {
+    emitChange([
+      ...rules,
+      { _id: uid(), userGroup: '', usingGroup: '', ratio: 1 },
+    ]);
+  }, [rules, emitChange]);
+
+  const removeRule = useCallback(
+    (id) => {
+      emitChange(rules.filter((r) => r._id !== id));
+    },
+    [rules, emitChange],
+  );
+
+  const groupOptions = useMemo(
+    () => groupNames.map((n) => ({ value: n, label: n })),
+    [groupNames],
+  );
+
+  const columns = useMemo(
+    () => [
+      {
+        title: t('用户分组'),
+        dataIndex: 'userGroup',
+        key: 'userGroup',
+        width: 200,
+        render: (_, record) => (
+          <Select
+            size='small'
+            filter
+            value={record.userGroup || undefined}
+            placeholder={t('选择用户分组')}
+            optionList={groupOptions}
+            onChange={(v) => updateRule(record._id, 'userGroup', v)}
+            style={{ width: '100%' }}
+            allowCreate
+            position='bottomLeft'
+          />
+        ),
+      },
+      {
+        title: t('使用分组'),
+        dataIndex: 'usingGroup',
+        key: 'usingGroup',
+        width: 200,
+        render: (_, record) => (
+          <Select
+            size='small'
+            filter
+            value={record.usingGroup || undefined}
+            placeholder={t('选择使用分组')}
+            optionList={groupOptions}
+            onChange={(v) => updateRule(record._id, 'usingGroup', v)}
+            style={{ width: '100%' }}
+            allowCreate
+            position='bottomLeft'
+          />
+        ),
+      },
+      {
+        title: t('倍率'),
+        dataIndex: 'ratio',
+        key: 'ratio',
+        width: 140,
+        render: (_, record) => (
+          <InputNumber
+            size='small'
+            min={0}
+            step={0.1}
+            value={record.ratio}
+            style={{ width: '100%' }}
+            onChange={(v) => updateRule(record._id, 'ratio', v ?? 0)}
+          />
+        ),
+      },
+      {
+        title: '',
+        key: 'actions',
+        width: 50,
+        render: (_, record) => (
+          <Popconfirm
+            title={t('确认删除该规则?')}
+            onConfirm={() => removeRule(record._id)}
+            position='left'
+          >
+            <Button
+              icon={<IconDelete />}
+              type='danger'
+              theme='borderless'
+              size='small'
+            />
+          </Popconfirm>
+        ),
+      },
+    ],
+    [t, groupOptions, updateRule, removeRule],
+  );
+
+  return (
+    <div>
+      <CardTable
+        columns={columns}
+        dataSource={rules}
+        rowKey='_id'
+        hidePagination
+        size='small'
+        empty={
+          <Text type='tertiary'>
+            {t('暂无规则,点击下方按钮添加')}
+          </Text>
+        }
+      />
+      <div className='mt-3 flex justify-center'>
+        <Button icon={<IconPlus />} theme='outline' onClick={addRule}>
+          {t('添加规则')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 276 - 0
web/src/pages/Setting/Ratio/components/GroupSpecialUsableRules.jsx

@@ -0,0 +1,276 @@
+import React, { useState, useCallback, useMemo } from 'react';
+import {
+  Button,
+  Input,
+  Select,
+  Tag,
+  Typography,
+  Popconfirm,
+} from '@douyinfe/semi-ui';
+import { IconPlus, IconDelete } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import CardTable from '../../../../components/common/ui/CardTable';
+
+const { Text } = Typography;
+
+let _idCounter = 0;
+const uid = () => `gsu_${++_idCounter}`;
+
+const OP_ADD = 'add';
+const OP_REMOVE = 'remove';
+const OP_APPEND = 'append';
+
+function parsePrefix(rawKey) {
+  if (rawKey.startsWith('+:')) {
+    return { op: OP_ADD, groupName: rawKey.slice(2) };
+  }
+  if (rawKey.startsWith('-:')) {
+    return { op: OP_REMOVE, groupName: rawKey.slice(2) };
+  }
+  return { op: OP_APPEND, groupName: rawKey };
+}
+
+function toRawKey(op, groupName) {
+  if (op === OP_ADD) return `+:${groupName}`;
+  if (op === OP_REMOVE) return `-:${groupName}`;
+  return groupName;
+}
+
+function parseJSON(str) {
+  if (!str || !str.trim()) return {};
+  try {
+    return JSON.parse(str);
+  } catch {
+    return {};
+  }
+}
+
+function flattenRules(nested) {
+  const rules = [];
+  for (const [userGroup, inner] of Object.entries(nested)) {
+    if (typeof inner !== 'object' || inner === null) continue;
+    for (const [rawKey, desc] of Object.entries(inner)) {
+      const { op, groupName } = parsePrefix(rawKey);
+      rules.push({
+        _id: uid(),
+        userGroup,
+        op,
+        targetGroup: groupName,
+        description: op === OP_REMOVE ? 'remove' : (typeof desc === 'string' ? desc : ''),
+      });
+    }
+  }
+  return rules;
+}
+
+function nestRules(rules) {
+  const result = {};
+  rules.forEach(({ userGroup, op, targetGroup, description }) => {
+    if (!userGroup || !targetGroup) return;
+    if (!result[userGroup]) result[userGroup] = {};
+    const key = toRawKey(op, targetGroup);
+    result[userGroup][key] = description;
+  });
+  return result;
+}
+
+export function serializeGroupSpecialUsable(rules) {
+  const nested = nestRules(rules);
+  return Object.keys(nested).length === 0
+    ? ''
+    : JSON.stringify(nested, null, 2);
+}
+
+const OP_TAG_MAP = {
+  [OP_ADD]: { color: 'green', label: '添加 (+:)' },
+  [OP_REMOVE]: { color: 'red', label: '移除 (-:)' },
+  [OP_APPEND]: { color: 'blue', label: '追加' },
+};
+
+export default function GroupSpecialUsableRules({
+  value,
+  groupNames = [],
+  onChange,
+}) {
+  const { t } = useTranslation();
+
+  const [rules, setRules] = useState(() => flattenRules(parseJSON(value)));
+
+  const emitChange = useCallback(
+    (newRules) => {
+      setRules(newRules);
+      onChange?.(serializeGroupSpecialUsable(newRules));
+    },
+    [onChange],
+  );
+
+  const updateRule = useCallback(
+    (id, field, val) => {
+      const next = rules.map((r) => {
+        if (r._id !== id) return r;
+        const updated = { ...r, [field]: val };
+        if (field === 'op' && val === OP_REMOVE) {
+          updated.description = 'remove';
+        } else if (field === 'op' && r.op === OP_REMOVE && val !== OP_REMOVE) {
+          if (updated.description === 'remove') updated.description = '';
+        }
+        return updated;
+      });
+      emitChange(next);
+    },
+    [rules, emitChange],
+  );
+
+  const addRule = useCallback(() => {
+    emitChange([
+      ...rules,
+      {
+        _id: uid(),
+        userGroup: '',
+        op: OP_APPEND,
+        targetGroup: '',
+        description: '',
+      },
+    ]);
+  }, [rules, emitChange]);
+
+  const removeRule = useCallback(
+    (id) => {
+      emitChange(rules.filter((r) => r._id !== id));
+    },
+    [rules, emitChange],
+  );
+
+  const groupOptions = useMemo(
+    () => groupNames.map((n) => ({ value: n, label: n })),
+    [groupNames],
+  );
+
+  const opOptions = useMemo(
+    () => [
+      { value: OP_ADD, label: t('添加 (+:)') },
+      { value: OP_REMOVE, label: t('移除 (-:)') },
+      { value: OP_APPEND, label: t('追加') },
+    ],
+    [t],
+  );
+
+  const columns = useMemo(
+    () => [
+      {
+        title: t('用户分组'),
+        dataIndex: 'userGroup',
+        key: 'userGroup',
+        width: 180,
+        render: (_, record) => (
+          <Select
+            size='small'
+            filter
+            value={record.userGroup || undefined}
+            placeholder={t('选择用户分组')}
+            optionList={groupOptions}
+            onChange={(v) => updateRule(record._id, 'userGroup', v)}
+            style={{ width: '100%' }}
+            allowCreate
+            position='bottomLeft'
+          />
+        ),
+      },
+      {
+        title: t('操作'),
+        dataIndex: 'op',
+        key: 'op',
+        width: 140,
+        render: (_, record) => (
+          <Select
+            size='small'
+            value={record.op}
+            optionList={opOptions}
+            onChange={(v) => updateRule(record._id, 'op', v)}
+            style={{ width: '100%' }}
+            renderSelectedItem={(optionNode) => {
+              const tagInfo = OP_TAG_MAP[optionNode.value] || {};
+              return (
+                <Tag size='small' color={tagInfo.color}>
+                  {optionNode.label}
+                </Tag>
+              );
+            }}
+          />
+        ),
+      },
+      {
+        title: t('目标分组'),
+        dataIndex: 'targetGroup',
+        key: 'targetGroup',
+        width: 180,
+        render: (_, record) => (
+          <Input
+            size='small'
+            value={record.targetGroup}
+            placeholder={t('分组名称')}
+            onChange={(v) => updateRule(record._id, 'targetGroup', v)}
+          />
+        ),
+      },
+      {
+        title: t('描述'),
+        dataIndex: 'description',
+        key: 'description',
+        render: (_, record) =>
+          record.op === OP_REMOVE ? (
+            <Text type='tertiary' size='small'>-</Text>
+          ) : (
+            <Input
+              size='small'
+              value={record.description}
+              placeholder={t('分组描述')}
+              onChange={(v) => updateRule(record._id, 'description', v)}
+            />
+          ),
+      },
+      {
+        title: '',
+        key: 'actions',
+        width: 50,
+        render: (_, record) => (
+          <Popconfirm
+            title={t('确认删除该规则?')}
+            onConfirm={() => removeRule(record._id)}
+            position='left'
+          >
+            <Button
+              icon={<IconDelete />}
+              type='danger'
+              theme='borderless'
+              size='small'
+            />
+          </Popconfirm>
+        ),
+      },
+    ],
+    [t, groupOptions, opOptions, updateRule, removeRule],
+  );
+
+  return (
+    <div>
+      <CardTable
+        columns={columns}
+        dataSource={rules}
+        rowKey='_id'
+        hidePagination
+        size='small'
+        empty={
+          <Text type='tertiary'>
+            {t('暂无规则,点击下方按钮添加')}
+          </Text>
+        }
+      />
+      <div className='mt-3 flex justify-center'>
+        <Button icon={<IconPlus />} theme='outline' onClick={addRule}>
+          {t('添加规则')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 242 - 0
web/src/pages/Setting/Ratio/components/GroupTable.jsx

@@ -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>
+  );
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов