From f1b716249fca093e400aec5fdb721a2782ed8eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E4=B8=AD=E6=B4=8B?= Date: Tue, 2 Jun 2026 03:52:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=88=86=E7=BB=84=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E9=A1=B5=E6=96=B0=E5=A2=9E=E3=80=8C=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E7=B3=BB=E6=95=B0=E3=80=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2(=E4=BA=8C=E7=BB=B4=20=E5=9F=9F=E5=90=8Dx?= =?UTF-8?q?=E5=88=86=E7=BB=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 domain-ratio-editor.tsx: 行式结构化编辑器(域名/分组(空=*)/系数, 增删) - group-ratio-form: GroupFormValues 加 DomainRatio; 在「特殊可用分组规则」下方渲染编辑器; json模式加 textarea - ratio-settings-card: groupSchema/默认值/normalized/reset/save 接入 DomainRatio - billing/models index 默认 + billing section-registry getGroupDefaults 映射 - zh i18n 文案 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../system-settings/billing/index.tsx | 1 + .../billing/section-registry.tsx | 1 + .../models/domain-ratio-editor.tsx | 191 ++++++++++++++++++ .../models/group-ratio-form.tsx | 27 +++ .../features/system-settings/models/index.tsx | 1 + .../models/ratio-settings-card.tsx | 13 ++ web/default/src/i18n/locales/zh.json | 7 + 7 files changed, 241 insertions(+) create mode 100644 web/default/src/features/system-settings/models/domain-ratio-editor.tsx diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index daad5066..2b2c0949 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -55,6 +55,7 @@ const defaultBillingSettings: BillingSettings = { GroupRatio: '', UserUsableGroups: '', GroupGroupRatio: '', + DomainRatio: '', AutoGroups: '', DefaultUseAutoGroup: false, 'group_ratio_setting.group_special_usable_group': '{}', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 1a1dc8a2..3342b5c6 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -44,6 +44,7 @@ const getGroupDefaults = (settings: BillingSettings) => ({ GroupRatio: settings.GroupRatio, UserUsableGroups: settings.UserUsableGroups, GroupGroupRatio: settings.GroupGroupRatio, + DomainRatio: settings.DomainRatio, AutoGroups: settings.AutoGroups, DefaultUseAutoGroup: settings.DefaultUseAutoGroup, GroupSpecialUsableGroup: diff --git a/web/default/src/features/system-settings/models/domain-ratio-editor.tsx b/web/default/src/features/system-settings/models/domain-ratio-editor.tsx new file mode 100644 index 00000000..9af9fa74 --- /dev/null +++ b/web/default/src/features/system-settings/models/domain-ratio-editor.tsx @@ -0,0 +1,191 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useEffect, useRef, useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' + +type Row = { + id: number + domain: string + group: string + ratio: string +} + +type DomainRatioEditorProps = { + value: string + onChange: (value: string) => void +} + +let rowSeq = 0 +const nextId = () => ++rowSeq + +// 把 JSON 字符串 {域名:{分组:系数}} 解析成扁平行 +function parseToRows(value: string): Row[] { + if (!value || !value.trim()) return [] + try { + const obj = JSON.parse(value) as Record> + const rows: Row[] = [] + for (const domain of Object.keys(obj)) { + const groupMap = obj[domain] || {} + for (const group of Object.keys(groupMap)) { + rows.push({ + id: nextId(), + domain, + group: group === '*' ? '' : group, + ratio: String(groupMap[group]), + }) + } + } + return rows + } catch { + return [] + } +} + +// 把扁平行序列化回 {域名:{分组|*:系数}},跳过非法行 +function rowsToJson(rows: Row[]): string { + const obj: Record> = {} + for (const r of rows) { + const domain = r.domain.trim().toLowerCase() + const ratio = Number(r.ratio) + if (!domain || r.ratio.trim() === '' || Number.isNaN(ratio)) continue + const group = r.group.trim() === '' ? '*' : r.group.trim() + if (!obj[domain]) obj[domain] = {} + obj[domain][group] = ratio + } + return JSON.stringify(obj) +} + +/** + * 域名计费系数编辑器:行式配置 "域名 × 命中分组 → 系数"。 + * 分组留空表示该域名默认(*)系数。最终倍率 = 模型倍率 × 命中分组倍率 × 此系数。 + */ +export function DomainRatioEditor({ value, onChange }: DomainRatioEditorProps) { + const { t } = useTranslation() + const [rows, setRows] = useState(() => parseToRows(value)) + const lastEmitted = useRef(rowsToJson(rows)) + + // 外部 value 变化(如重置/切换)且与当前不一致时,重新解析 + useEffect(() => { + try { + const incoming = value && value.trim() ? value : '{}' + const current = lastEmitted.current && lastEmitted.current.trim() ? lastEmitted.current : '{}' + if (JSON.stringify(JSON.parse(incoming)) !== JSON.stringify(JSON.parse(current))) { + setRows(parseToRows(value)) + lastEmitted.current = value + } + } catch { + // 忽略解析异常 + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + const commit = (next: Row[]) => { + setRows(next) + const json = rowsToJson(next) + lastEmitted.current = json + onChange(json) + } + + const updateRow = (id: number, patch: Partial) => { + commit(rows.map((r) => (r.id === id ? { ...r, ...patch } : r))) + } + + const addRow = () => { + commit([...rows, { id: nextId(), domain: '', group: '', ratio: '' }]) + } + + const removeRow = (id: number) => { + commit(rows.filter((r) => r.id !== id)) + } + + return ( + + + {t('Domain billing ratios')} + + {t( + 'Multiply the final group ratio by a coefficient based on the request domain (X-Forwarded-Host / Host) and the matched group. Leave group empty to apply to all groups on that domain. Final = model ratio × group ratio × this coefficient.' + )} + + + + {rows.length === 0 ? ( +

+ {t('No domain ratios configured.')} +

+ ) : ( +
+
+ {t('Domain')} + {t('Group (empty = all)')} + {t('Coefficient')} + +
+ {rows.map((r) => ( +
+ updateRow(r.id, { domain: e.target.value })} + /> + updateRow(r.id, { group: e.target.value })} + /> + updateRow(r.id, { ratio: e.target.value })} + /> + +
+ ))} +
+ )} + +
+
+ ) +} diff --git a/web/default/src/features/system-settings/models/group-ratio-form.tsx b/web/default/src/features/system-settings/models/group-ratio-form.tsx index c727adcf..c5a91bfa 100644 --- a/web/default/src/features/system-settings/models/group-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/group-ratio-form.tsx @@ -58,12 +58,14 @@ import { import { SettingsPageActionsPortal } from '../components/settings-page-context' import { GroupRatioVisualEditor } from './group-ratio-visual-editor' import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor' +import { DomainRatioEditor } from './domain-ratio-editor' type GroupFormValues = { GroupRatio: string TopupGroupRatio: string UserUsableGroups: string GroupGroupRatio: string + DomainRatio: string AutoGroups: string DefaultUseAutoGroup: boolean GroupSpecialUsableGroup: string @@ -153,6 +155,11 @@ export const GroupRatioForm = memo(function GroupRatioForm({ } /> + handleFieldChange('DomainRatio', value)} + /> + + ( + + {t('Domain billing ratios')} + +