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:
@@ -55,6 +55,7 @@ const defaultBillingSettings: BillingSettings = {
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
DomainRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
|
||||
@@ -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:
|
||||
|
||||
191
web/default/src/features/system-settings/models/domain-ratio-editor.tsx
vendored
Normal file
191
web/default/src/features/system-settings/models/domain-ratio-editor.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -59,6 +59,7 @@ const defaultModelSettings: ModelSettings = {
|
||||
GroupRatio: '',
|
||||
UserUsableGroups: '',
|
||||
GroupGroupRatio: '',
|
||||
DomainRatio: '',
|
||||
AutoGroups: '',
|
||||
DefaultUseAutoGroup: false,
|
||||
'group_ratio_setting.group_special_usable_group': '{}',
|
||||
|
||||
@@ -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(
|
||||
|
||||
7
web/default/src/i18n/locales/zh.json
vendored
7
web/default/src/i18n/locales/zh.json
vendored
@@ -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": "自动禁用",
|
||||
|
||||
Reference in New Issue
Block a user