feat(web): 分组定价页新增「域名计费系数」配置界面(二维 域名x分组)

- 新增 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 03:52:28 +08:00
parent a66ef53c38
commit f1b716249f
7 changed files with 241 additions and 0 deletions

View File

@@ -55,6 +55,7 @@ const defaultBillingSettings: BillingSettings = {
GroupRatio: '',
UserUsableGroups: '',
GroupGroupRatio: '',
DomainRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',

View File

@@ -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:

View File

@@ -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 <https://www.gnu.org/licenses/>.
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<string, Record<string, number>>
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<string, Record<string, number>> = {}
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<Row[]>(() => parseToRows(value))
const lastEmitted = useRef<string>(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<Row>) => {
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 (
<Card>
<CardHeader>
<CardTitle>{t('Domain billing ratios')}</CardTitle>
<CardDescription>
{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.'
)}
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{rows.length === 0 ? (
<p className='text-muted-foreground text-sm'>
{t('No domain ratios configured.')}
</p>
) : (
<div className='space-y-2'>
<div className='text-muted-foreground grid grid-cols-[1fr_1fr_120px_40px] gap-2 text-xs'>
<span>{t('Domain')}</span>
<span>{t('Group (empty = all)')}</span>
<span>{t('Coefficient')}</span>
<span />
</div>
{rows.map((r) => (
<div
key={r.id}
className='grid grid-cols-[1fr_1fr_120px_40px] items-center gap-2'
>
<Input
placeholder='api1.example.com'
value={r.domain}
onChange={(e) => updateRow(r.id, { domain: e.target.value })}
/>
<Input
placeholder='*'
value={r.group}
onChange={(e) => updateRow(r.id, { group: e.target.value })}
/>
<Input
type='number'
step='any'
placeholder='1.2'
value={r.ratio}
onChange={(e) => updateRow(r.id, { ratio: e.target.value })}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => removeRow(r.id)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
)}
<Button type='button' variant='outline' size='sm' onClick={addRow}>
<Plus className='mr-2 h-4 w-4' />
{t('Add domain ratio')}
</Button>
</CardContent>
</Card>
)
}

View File

@@ -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({
}
/>
<DomainRatioEditor
value={form.watch('DomainRatio')}
onChange={(value) => handleFieldChange('DomainRatio', value)}
/>
<FormField
control={form.control}
name='DefaultUseAutoGroup'
@@ -276,6 +283,26 @@ export const GroupRatioForm = memo(function GroupRatioForm({
)}
/>
<FormField
control={form.control}
name='DomainRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Domain billing ratios')}</FormLabel>
<FormControl>
<Textarea rows={6} {...field} />
</FormControl>
<FormDescription>
{t(
'Nested JSON: domain → { group | "*": coefficient }. Multiplies the final group ratio by domain × matched group. Example:'
)}{' '}
{`{ "api1.example.com": { "*": 1.2 }, "api2.example.com": { "*": 1.5, "vip": 2 } }`}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='GroupSpecialUsableGroup'

View File

@@ -59,6 +59,7 @@ const defaultModelSettings: ModelSettings = {
GroupRatio: '',
UserUsableGroups: '',
GroupGroupRatio: '',
DomainRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',

View File

@@ -169,6 +169,15 @@ const groupSchema = z.object({
})
}
}),
DomainRatio: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: result.message || 'Invalid JSON',
})
}
}),
AutoGroups: z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, {
predicate: (parsed) =>
@@ -292,6 +301,7 @@ export function RatioSettingsCard({
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
DomainRatio: formatJsonForTextarea(groupDefaults.DomainRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
@@ -339,6 +349,7 @@ export function RatioSettingsCard({
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
DomainRatio: normalizeJsonString(groupDefaults.DomainRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
@@ -352,6 +363,7 @@ export function RatioSettingsCard({
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
DomainRatio: formatJsonForTextarea(groupDefaults.DomainRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
@@ -406,6 +418,7 @@ export function RatioSettingsCard({
TopupGroupRatio: normalizeJsonString(values.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
DomainRatio: normalizeJsonString(values.DomainRatio),
AutoGroups: normalizeJsonString(values.AutoGroups),
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(

View File

@@ -439,6 +439,13 @@
"Auto": "自动",
"Auto (Circuit Breaker)": "自动分组(熔断)",
"Auto assignment order": "自动分配顺序",
"Domain billing ratios": "域名计费系数",
"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.": "根据请求域名(X-Forwarded-Host / Host)与实际命中的分组,给最终分组倍率再乘一个系数。分组留空表示对该域名所有分组生效。最终 = 模型倍率 × 分组倍率 × 此系数。",
"No domain ratios configured.": "尚未配置域名系数。",
"Group (empty = all)": "分组(留空=全部)",
"Coefficient": "系数",
"Add domain ratio": "添加域名系数",
"Nested JSON: domain → { group | \"*\": coefficient }. Multiplies the final group ratio by domain × matched group. Example:": "嵌套 JSON域名 → { 分组 | \"*\": 系数 }。按 域名×命中分组 给最终分组倍率乘系数。示例:",
"Auto Ban": "自动封禁",
"Auto detect (default)": "自动检测(默认)",
"Auto Disabled": "自动禁用",