feat(admin): 优化后台运维与任务管理

完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。

新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。

验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
2026-07-25 01:32:49 +08:00
parent b29d539d49
commit de10875439
38 changed files with 5015 additions and 425 deletions
@@ -1,79 +0,0 @@
import { useEffect, useState } from 'react';
import { ShieldCheck } from 'lucide-react';
import type {
BaseModelCatalogItem,
GatewayAccessRule,
GatewayAccessRuleBatchRequest,
IntegrationPlatform,
PlatformModel,
UserGroup,
} from '@easyai-ai-gateway/contracts';
import { Badge, Card, CardContent, CardHeader, CardTitle, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { AccessPermissionEditor } from './AccessPermissionEditor';
export function AccessRulesPanel(props: {
accessRules: GatewayAccessRule[];
baseModels: BaseModelCatalogItem[];
message: string;
platformModels: PlatformModel[];
platforms: IntegrationPlatform[];
state: LoadState;
userGroups: UserGroup[];
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
}) {
const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? '');
useEffect(() => {
if (!selectedGroupId && props.userGroups[0]?.id) {
setSelectedGroupId(props.userGroups[0].id);
}
}, [props.userGroups, selectedGroupId]);
return (
<div className="pageStack">
<Card>
<CardHeader>
<div>
<CardTitle>访</CardTitle>
<p className="mutedText">使使/</p>
</div>
<Badge variant="secondary">{props.accessRules.length} </Badge>
</CardHeader>
<CardContent>
{props.message && <p className="formMessage">{props.message}</p>}
<div className="accessGroupToolbar">
<Label>
<Select size="sm" value={selectedGroupId} onChange={(event) => setSelectedGroupId(event.target.value)}>
{props.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}
</Select>
</Label>
<div className="accessGroupHint">
<ShieldCheck size={15} />
<span></span>
</div>
</div>
</CardContent>
</Card>
{selectedGroupId ? (
<AccessPermissionEditor
accessRules={props.accessRules}
metadataMode="user_group_permission_tree"
platformModels={props.platformModels}
platforms={props.platforms}
state={props.state}
subjectId={selectedGroupId}
subjectType="user_group"
onBatchAccessRules={props.onBatchAccessRules}
/>
) : (
<div className="emptyState">
<strong></strong>
<span></span>
</div>
)}
</div>
);
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { adminTaskAdvancedFilterCount, adminTaskPagination } from './AdminTasksPanel';
describe('admin task pagination', () => {
it('keeps empty results on the first page', () => {
expect(adminTaskPagination(0, 8, 20)).toEqual({
totalPages: 1,
currentPage: 1,
pageStart: 0,
pageEnd: 0,
});
});
it('clamps an out-of-range page to the final partial page', () => {
expect(adminTaskPagination(41, 99, 20)).toEqual({
totalPages: 3,
currentPage: 3,
pageStart: 41,
pageEnd: 41,
});
});
});
describe('admin task advanced filters', () => {
it('counts only filters moved into the advanced dialog', () => {
expect(adminTaskAdvancedFilterCount({
tenantId: 'tenant-id',
status: 'failed',
model: '',
apiKey: 'ops-key',
})).toBe(3);
});
});
@@ -0,0 +1,546 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw, RotateCcw, Search, SlidersHorizontal } from 'lucide-react';
import type { AdminGatewayTask, GatewayTask } from '@easyai-ai-gateway/contracts';
import { getAdminTask, listAdminTaskParamPreprocessing } from '../../api';
import {
Button,
Card,
CardContent,
ConfirmDialog,
DateTimeRangePicker,
FormDialog,
Input,
Label,
Select,
Table,
TableFooter,
TableHead,
TablePageActions,
TableRow,
TableToolbar,
TableViewportLayout,
} from '../../components/ui';
import type { ConsoleData } from '../../app-state';
import type { AdminTaskQuery, LoadState } from '../../types';
import { TaskRecord } from '../WorkspacePage';
const taskPageSizeOptions = [10, 20, 50, 100];
const taskStatuses = ['queued', 'submitting', 'running', 'succeeded', 'failed', 'cancelled'];
const billingStatuses = ['not_started', 'pending', 'processing', 'settled', 'released', 'retryable_failed', 'manual_review', 'not_required'];
type AdminTaskAdvancedFilters = Pick<
AdminTaskQuery,
'tenantId' | 'userId' | 'userGroupId' | 'status' | 'platformId' | 'model' | 'modelType' | 'runMode' | 'billingStatus' | 'apiKey'
>;
export function AdminTasksPanel(props: {
data: ConsoleData;
query: AdminTaskQuery;
state: LoadState;
token: string;
total: number;
updatedAt: number | null;
onQueryChange: (query: AdminTaskQuery) => void;
onRefresh: () => Promise<void>;
}) {
const [localMessage, setLocalMessage] = useState('');
const [pageJump, setPageJump] = useState(String(props.query.page));
const [detail, setDetail] = useState<AdminGatewayTask | null>(null);
const [detailSensitive, setDetailSensitive] = useState<'masked' | 'full'>('masked');
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState('');
const [revealConfirmOpen, setRevealConfirmOpen] = useState(false);
const [advancedFilterOpen, setAdvancedFilterOpen] = useState(false);
const [advancedFilterDraft, setAdvancedFilterDraft] = useState<AdminTaskAdvancedFilters>(() => adminTaskAdvancedFilters(props.query));
const query = props.query;
const tasks = props.data.adminTasks;
const { totalPages, currentPage, pageStart, pageEnd } = adminTaskPagination(props.total, query.page, query.pageSize);
const hasActiveFilters = adminTaskHasActiveFilters(query);
const advancedFilterCount = adminTaskAdvancedFilterCount(query);
const users = useMemo(
() => props.data.users.slice().sort((a, b) => adminUserLabel(a).localeCompare(adminUserLabel(b))),
[props.data.users],
);
const tenants = useMemo(
() => props.data.tenants.slice().sort((a, b) => (a.name || a.tenantKey).localeCompare(b.name || b.tenantKey)),
[props.data.tenants],
);
const platforms = useMemo(
() => props.data.platforms.slice().sort((a, b) => (a.internalName || a.name).localeCompare(b.internalName || b.name)),
[props.data.platforms],
);
const models = useMemo(() => {
const values = props.data.models
.flatMap((model) => [model.modelAlias, model.modelName, model.providerModelName])
.filter((value): value is string => Boolean(value));
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
}, [props.data.models]);
const modelTypes = useMemo(() => {
const values = [
...props.data.models.flatMap((model) => model.modelType),
...tasks.map((task) => task.modelType || ''),
].filter((value): value is string => Boolean(value));
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b));
}, [props.data.models, tasks]);
useEffect(() => {
if (query.page > totalPages) {
props.onQueryChange({ ...query, page: totalPages });
}
}, [props.onQueryChange, query, totalPages]);
useEffect(() => {
setPageJump(String(currentPage));
}, [currentPage]);
function updateQuery(patch: Partial<AdminTaskQuery>) {
props.onQueryChange({ ...query, ...patch, page: patch.page ?? 1 });
}
function resetFilters() {
props.onQueryChange({
query: '',
tenantId: '',
userId: '',
userGroupId: '',
status: '',
platformId: '',
model: '',
modelType: '',
runMode: '',
billingStatus: '',
apiKey: '',
createdFrom: '',
createdTo: '',
page: 1,
pageSize: query.pageSize,
});
}
function openAdvancedFilters() {
setAdvancedFilterDraft(adminTaskAdvancedFilters(query));
setAdvancedFilterOpen(true);
}
function updateAdvancedFilter(patch: Partial<AdminTaskAdvancedFilters>) {
setAdvancedFilterDraft((current) => ({ ...current, ...patch }));
}
function clearAdvancedFilters() {
setAdvancedFilterDraft(emptyAdminTaskAdvancedFilters());
}
function applyAdvancedFilters() {
props.onQueryChange({ ...query, ...advancedFilterDraft, page: 1 });
setAdvancedFilterOpen(false);
}
async function copyTaskRequestId(task: GatewayTask) {
if (!task.requestId) return;
await navigator.clipboard.writeText(task.requestId);
setLocalMessage(`已复制 RequestID${task.requestId}`);
}
async function openDetail(task: GatewayTask) {
setDetailError('');
setDetailLoading(true);
setDetailSensitive('masked');
setDetail('adminContext' in task ? task as AdminGatewayTask : null);
try {
setDetail(await getAdminTask(props.token, task.id, 'masked'));
} catch (err) {
setDetailError(err instanceof Error ? err.message : '加载任务详情失败');
} finally {
setDetailLoading(false);
}
}
async function revealSensitiveDetail() {
if (!detail) return;
setRevealConfirmOpen(false);
setDetailLoading(true);
setDetailError('');
try {
setDetail(await getAdminTask(props.token, detail.id, 'full'));
setDetailSensitive('full');
} catch (err) {
setDetailError(err instanceof Error ? err.message : '加载完整任务详情失败');
} finally {
setDetailLoading(false);
}
}
function submitPageJump() {
const parsed = Number(pageJump);
if (!Number.isFinite(parsed) || parsed <= 0) {
setPageJump(String(currentPage));
return;
}
props.onQueryChange({ ...query, page: Math.min(totalPages, Math.max(1, Math.floor(parsed))) });
}
return (
<section className="pageStack">
<Card className="shTableViewportCard adminTaskRecordViewport">
<CardContent className="shTableViewportPanel">
{localMessage && <p className="formMessage">{localMessage}</p>}
<TableViewportLayout>
<TableToolbar className="adminTaskFilters">
<Label className="adminTaskSearchLabel">
<span className="taskRecordSearchBox">
<Search size={15} />
<Input
value={query.query}
placeholder="任务、用户、租户、模型、平台、RequestID"
onChange={(event) => updateQuery({ query: event.target.value })}
/>
</span>
</Label>
<Label className="adminTaskRangeLabel">
<DateTimeRangePicker
from={query.createdFrom}
fromPlaceholder="开始日期"
to={query.createdTo}
toPlaceholder="结束日期"
onChange={(value) => updateQuery({ createdFrom: value.from, createdTo: value.to })}
/>
</Label>
<div className="adminTaskToolbarActions">
<Button type="button" variant="outline" size="sm" onClick={openAdvancedFilters}>
<SlidersHorizontal size={14} />
{advancedFilterCount ? `${advancedFilterCount}` : ''}
</Button>
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
<RotateCcw size={14} />
</Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => void props.onRefresh()}>
<RefreshCw size={14} />
</Button>
</div>
</TableToolbar>
<div className="adminTaskResultSummary">
<span> {props.total} </span>
<small>{formatAdminTaskUpdatedAt(props.updatedAt)}</small>
</div>
{tasks.length ? (
<Table className="shTableViewport adminTaskRecordTable" density="compact">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead> / </TableHead>
<TableHead>RequestID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead>Token</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> JSON</TableHead>
</TableRow>
{tasks.map((task) => (
<TaskRecord
admin
key={task.id}
task={task}
token={props.token}
onCopyRequestId={copyTaskRequestId}
onLoadParamPreprocessing={listAdminTaskParamPreprocessing}
onOpenJson={openDetail}
/>
))}
</Table>
) : (
<div className="emptyState">
<strong>{hasActiveFilters ? '没有匹配的任务' : '暂无任务'}</strong>
<span>{hasActiveFilters ? '调整筛选条件后再试。' : '任务创建后会在这里显示。'}</span>
</div>
)}
<TableFooter>
<div className="shTableFooterGroup">
<Label>
<Select
size="sm"
value={String(query.pageSize)}
onChange={(event) => props.onQueryChange({ ...query, page: 1, pageSize: Number(event.target.value) })}
>
{taskPageSizeOptions.map((option) => <option key={option} value={option}>{option} </option>)}
</Select>
</Label>
<span> {props.total} · {pageStart}-{pageEnd}</span>
</div>
<form
className="shTablePageJump"
onSubmit={(event) => {
event.preventDefault();
submitPageJump();
}}
>
<span> {currentPage} / {totalPages} </span>
<span></span>
<Input
aria-label="跳转页码"
inputMode="numeric"
min={1}
max={totalPages}
size="xs"
type="number"
value={pageJump}
onChange={(event) => setPageJump(event.target.value)}
/>
<span></span>
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}></Button>
</form>
<TablePageActions>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => props.onQueryChange({ ...query, page: Math.max(1, currentPage - 1) })}
>
<ChevronLeft size={14} />
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => props.onQueryChange({ ...query, page: Math.min(totalPages, currentPage + 1) })}
>
<ChevronRight size={14} />
</Button>
</TablePageActions>
</TableFooter>
</TableViewportLayout>
</CardContent>
</Card>
<FormDialog
ariaLabel="管理员任务高级筛选"
bodyClassName="adminTaskAdvancedDialogBody"
className="adminTaskAdvancedDialog"
closeLabel="关闭"
footer={(
<>
<Button type="button" variant="outline" disabled={!adminTaskAdvancedFilterCount(advancedFilterDraft)} onClick={clearAdvancedFilters}>
<RotateCcw size={15} />
</Button>
<Button type="submit">
<SlidersHorizontal size={15} />
</Button>
</>
)}
open={advancedFilterOpen}
title="高级筛选"
onClose={() => setAdvancedFilterOpen(false)}
onSubmit={(event) => {
event.preventDefault();
applyAdvancedFilters();
}}
>
<FilterSelect
label="租户"
value={advancedFilterDraft.tenantId}
onChange={(value) => updateAdvancedFilter({ tenantId: value, userId: '' })}
>
{tenants.map((tenant) => <option value={tenant.id} key={tenant.id}>{tenant.name || tenant.tenantKey}</option>)}
</FilterSelect>
<FilterSelect label="用户" value={advancedFilterDraft.userId} onChange={(value) => updateAdvancedFilter({ userId: value })}>
{users.filter((user) => !advancedFilterDraft.tenantId || user.gatewayTenantId === advancedFilterDraft.tenantId).map((user) => (
<option value={user.id} key={user.id}>{adminUserLabel(user)}</option>
))}
</FilterSelect>
<FilterSelect label="用户组" value={advancedFilterDraft.userGroupId} onChange={(value) => updateAdvancedFilter({ userGroupId: value })}>
{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name || group.groupKey}</option>)}
</FilterSelect>
<FilterSelect label="状态" value={advancedFilterDraft.status} onChange={(value) => updateAdvancedFilter({ status: value })}>
{taskStatuses.map((status) => <option value={status} key={status}>{status}</option>)}
</FilterSelect>
<FilterSelect label="平台" value={advancedFilterDraft.platformId} onChange={(value) => updateAdvancedFilter({ platformId: value })}>
{platforms.map((platform) => <option value={platform.id} key={platform.id}>{platform.internalName || platform.name}</option>)}
</FilterSelect>
<FilterSelect label="模型" value={advancedFilterDraft.model} onChange={(value) => updateAdvancedFilter({ model: value })}>
{models.map((model) => <option value={model} key={model}>{model}</option>)}
</FilterSelect>
<FilterSelect label="类型" value={advancedFilterDraft.modelType} onChange={(value) => updateAdvancedFilter({ modelType: value })}>
{modelTypes.map((modelType) => <option value={modelType} key={modelType}>{modelType}</option>)}
</FilterSelect>
<FilterSelect label="运行模式" value={advancedFilterDraft.runMode} onChange={(value) => updateAdvancedFilter({ runMode: value })}>
<option value="production">production</option>
<option value="simulation">simulation</option>
</FilterSelect>
<FilterSelect label="计费状态" value={advancedFilterDraft.billingStatus} onChange={(value) => updateAdvancedFilter({ billingStatus: value })}>
{billingStatuses.map((status) => <option value={status} key={status}>{status}</option>)}
</FilterSelect>
<Label>
API Key
<Input
value={advancedFilterDraft.apiKey}
placeholder="名称、ID 或前缀"
onChange={(event) => updateAdvancedFilter({ apiKey: event.target.value })}
/>
</Label>
</FormDialog>
<FormDialog
ariaLabel="查看管理员任务原始 JSON"
bodyClassName="taskJsonDialogBody"
className="taskJsonDialog"
footer={(
<>
{detailSensitive === 'masked' && (
<Button type="button" variant="outline" size="sm" disabled={!detail || detailLoading} onClick={() => setRevealConfirmOpen(true)}>
</Button>
)}
<Button type="button" size="sm" onClick={() => setDetail(null)}></Button>
</>
)}
open={Boolean(detail)}
title={detailSensitive === 'full' ? '任务原始 JSON(完整数据)' : '任务原始 JSON(默认脱敏)'}
onClose={() => setDetail(null)}
onSubmit={(event) => event.preventDefault()}
>
{detailError && <p className="formError">{detailError}</p>}
{detailLoading && !detail ? <p className="mutedText"></p> : (
<pre className="taskJsonPreview">{detail ? JSON.stringify(detail, null, 2) : ''}</pre>
)}
</FormDialog>
<ConfirmDialog
confirmLabel="查看完整数据"
description="完整数据可能包含用户输入、授权头或第三方返回内容,仅应在排障所需时查看。"
loading={detailLoading}
open={revealConfirmOpen}
title="确认查看未脱敏任务详情?"
onCancel={() => setRevealConfirmOpen(false)}
onConfirm={revealSensitiveDetail}
/>
</section>
);
}
function FilterSelect(props: {
children: ReactNode;
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Label>
{props.label}
<Select value={props.value || 'all'} onChange={(event) => props.onChange(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"></option>
{props.children}
</Select>
</Label>
);
}
function adminTaskHasActiveFilters(query: AdminTaskQuery) {
return Boolean(
query.query ||
query.tenantId ||
query.userId ||
query.userGroupId ||
query.status ||
query.platformId ||
query.model ||
query.modelType ||
query.runMode ||
query.billingStatus ||
query.apiKey ||
query.createdFrom ||
query.createdTo
);
}
function emptyAdminTaskAdvancedFilters(): AdminTaskAdvancedFilters {
return {
tenantId: '',
userId: '',
userGroupId: '',
status: '',
platformId: '',
model: '',
modelType: '',
runMode: '',
billingStatus: '',
apiKey: '',
};
}
function adminTaskAdvancedFilters(query: AdminTaskQuery): AdminTaskAdvancedFilters {
return {
tenantId: query.tenantId,
userId: query.userId,
userGroupId: query.userGroupId,
status: query.status,
platformId: query.platformId,
model: query.model,
modelType: query.modelType,
runMode: query.runMode,
billingStatus: query.billingStatus,
apiKey: query.apiKey,
};
}
export function adminTaskAdvancedFilterCount(query: Partial<AdminTaskAdvancedFilters>) {
const keys: Array<keyof AdminTaskAdvancedFilters> = [
'tenantId',
'userId',
'userGroupId',
'status',
'platformId',
'model',
'modelType',
'runMode',
'billingStatus',
'apiKey',
];
return keys.filter((key) => Boolean(query[key])).length;
}
function adminUserLabel(user: ConsoleData['users'][number]) {
const primary = user.displayName || user.username || user.userKey;
return user.email ? `${primary} · ${user.email}` : primary;
}
function formatAdminTaskUpdatedAt(value: number | null) {
if (!value) return '尚未刷新';
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(value);
}
export function adminTaskPagination(total: number, page: number, pageSize: number) {
const safeTotal = Math.max(0, Math.floor(total));
const safePageSize = Math.max(1, Math.floor(pageSize));
const totalPages = Math.max(1, Math.ceil(safeTotal / safePageSize));
const currentPage = Math.min(totalPages, Math.max(1, Math.floor(page)));
return {
totalPages,
currentPage,
pageStart: safeTotal ? Math.min((currentPage - 1) * safePageSize + 1, safeTotal) : 0,
pageEnd: Math.min(currentPage * safePageSize, safeTotal),
};
}
@@ -1,5 +1,5 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import { Pencil, Plus, RotateCcw, Search, SlidersHorizontal, Trash2 } from 'lucide-react';
import type {
BaseModelCatalogItem,
BaseModelUpsertRequest,
@@ -7,7 +7,7 @@ import type {
PricingRuleSet,
RuntimePolicySet,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui';
import { Badge, Button, Card, CardContent, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
import { BaseModelCapabilityEditor } from './BaseModelCapabilityEditor';
import {
@@ -90,6 +90,7 @@ export function BaseModelCatalogPanel(props: {
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ModelForm>(() => createEmptyForm());
const [localError, setLocalError] = useState('');
@@ -202,69 +203,114 @@ export function BaseModelCatalogPanel(props: {
}
}
function resetFilters() {
setQuery('');
setProviderFilter('all');
setTypeFilter('all');
setStatusFilter('active');
}
const advancedFilterCount = [
providerFilter !== 'all',
typeFilter !== 'all',
statusFilter !== 'active',
].filter(Boolean).length;
const filterActive = Boolean(query) || advancedFilterCount > 0;
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.baseModels.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p> server-main integration-platform</p>
<div className="inlineActions">
<Button type="button" variant="outline" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
<RotateCcw size={15} />
<Card className="compactAdminTableCard">
<CardContent className="compactAdminTableContent">
<div className="compactAdminToolbar catalogCompactToolbar">
<span className="platformSearchBox compactAdminSearch">
<Search size={15} />
<Input
aria-label="搜索基准模型"
value={query}
placeholder="模型名称、调用名、Provider 模型名、Canonical Key 或别名"
onChange={(event) => setQuery(event.target.value)}
/>
</span>
<div className="compactAdminToolbarActions">
<Button type="button" variant="outline" size="sm" onClick={() => setFilterDialogOpen(true)}>
<SlidersHorizontal size={14} />
{advancedFilterCount ? `${advancedFilterCount}` : ''}
</Button>
<Button type="button" variant="outline" size="sm" disabled={!filterActive} onClick={resetFilters}>
<RotateCcw size={14} />
</Button>
<Button type="button" variant="outline" size="sm" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
<RotateCcw size={14} />
</Button>
<Button type="button" onClick={openCreateDialog}>
<Button type="button" size="sm" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
<option value="all"></option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
<Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all"></option>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
<Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="active"> Active</option>
<option value="deprecated"> Deprecated</option>
<option value="hidden"> Hidden</option>
<option value="all"></option>
</Select>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
<section className="baseModelGrid">
{filteredModels.map((model) => (
<ModelCard
key={model.id}
model={model}
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
providerName={providerNames.get(model.providerKey)}
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
onDelete={() => setPendingDeleteModel(model)}
onEdit={() => editModel(model)}
onReset={() => setPendingResetModel(model)}
/>
))}
{!filteredModels.length && (
<div className="emptyState">
<strong></strong>
</div>
)}
</section>
</CardContent>
</Card>
<section className="baseModelGrid">
{filteredModels.map((model) => (
<ModelCard
key={model.id}
model={model}
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
providerName={providerNames.get(model.providerKey)}
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
onDelete={() => setPendingDeleteModel(model)}
onEdit={() => editModel(model)}
onReset={() => setPendingResetModel(model)}
/>
))}
{!filteredModels.length && (
<Card>
<CardContent className="emptyState">
<strong></strong>
</CardContent>
</Card>
<FormDialog
ariaLabel="基准模型高级筛选"
className="compactFilterDialog"
footer={(
<>
<Button type="submit"></Button>
<Button type="button" variant="outline" onClick={() => {
setProviderFilter('all');
setTypeFilter('all');
setStatusFilter('active');
}}>
</Button>
</>
)}
</section>
open={filterDialogOpen}
title="高级筛选"
onClose={() => setFilterDialogOpen(false)}
onSubmit={(event) => {
event.preventDefault();
setFilterDialogOpen(false);
}}
>
<Label>Provider<Select value={providerFilter} onChange={(event) => setProviderFilter(event.target.value)}>
<option value="all"> Provider</option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select></Label>
<Label><Select value={typeFilter} onChange={(event) => setTypeFilter(event.target.value)}>
<option value="all"></option>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select></Label>
<Label><Select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}>
<option value="active"> Active</option>
<option value="deprecated"> Deprecated</option>
<option value="hidden"> Hidden</option>
<option value="all"></option>
</Select></Label>
</FormDialog>
<FormDialog
ariaLabel={editingId ? '编辑基准模型' : '新增基准模型'}
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { retrySettlementBatch } from './BillingSettlementsPanel';
describe('billing settlement batch processing', () => {
it('continues after individual failures and reports failed settlement IDs', async () => {
const active = new Set<string>();
let peakConcurrency = 0;
const retryOne = vi.fn(async (item: { id: string }) => {
active.add(item.id);
peakConcurrency = Math.max(peakConcurrency, active.size);
await Promise.resolve();
active.delete(item.id);
if (item.id === 'failed') throw new Error('retry failed');
});
const result = await retrySettlementBatch(
[{ id: 'first' }, { id: 'failed' }, { id: 'last' }],
retryOne,
2,
);
expect(result).toEqual({ failedIds: ['failed'], succeeded: 2 });
expect(retryOne).toHaveBeenCalledTimes(3);
expect(peakConcurrency).toBeLessThanOrEqual(2);
});
});
@@ -1,15 +1,32 @@
import { useCallback, useEffect, useState } from 'react';
import { AlertTriangle, ReceiptText, RefreshCw, RotateCcw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, RefreshCw, RotateCcw, Search } from 'lucide-react';
import type { BillingSettlement } from '@easyai-ai-gateway/contracts';
import { listBillingSettlements, retryBillingSettlement } from '../../api';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
import {
Badge,
Button,
Card,
CardContent,
Checkbox,
ConfirmDialog,
Input,
Select,
Table,
TableCell,
TableHead,
TableRow,
} from '../../components/ui';
export function BillingSettlementsPanel(props: { token: string }) {
const [items, setItems] = useState<BillingSettlement[]>([]);
const [query, setQuery] = useState('');
const [status, setStatus] = useState('');
const [loading, setLoading] = useState(false);
const [retrying, setRetrying] = useState('');
const [batchRetrying, setBatchRetrying] = useState(false);
const [confirming, setConfirming] = useState<BillingSettlement | null>(null);
const [batchConfirming, setBatchConfirming] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [message, setMessage] = useState('');
const load = useCallback(async (signal?: AbortSignal) => {
@@ -32,13 +49,44 @@ export function BillingSettlementsPanel(props: { token: string }) {
return () => controller.abort();
}, [load]);
const filteredItems = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) => [
item.id,
item.taskId,
item.action,
item.status,
item.lastErrorCode,
item.manualReviewReason,
].filter(Boolean).join(' ').toLowerCase().includes(keyword));
}, [items, query]);
const retryableItems = useMemo(() => items.filter(canRetry), [items]);
const visibleRetryableItems = useMemo(() => filteredItems.filter(canRetry), [filteredItems]);
const selectedItems = useMemo(
() => retryableItems.filter((item) => selectedIds.has(item.id)),
[retryableItems, selectedIds],
);
const allVisibleSelected = visibleRetryableItems.length > 0
&& visibleRetryableItems.every((item) => selectedIds.has(item.id));
const someVisibleSelected = visibleRetryableItems.some((item) => selectedIds.has(item.id));
const busy = batchRetrying || Boolean(retrying);
useEffect(() => {
const retryableIds = new Set(retryableItems.map((item) => item.id));
setSelectedIds((current) => {
const next = new Set(Array.from(current).filter((id) => retryableIds.has(id)));
return next.size === current.size ? current : next;
});
}, [retryableItems]);
async function retry(item: BillingSettlement) {
setRetrying(item.id);
setMessage('');
try {
await retryBillingSettlement(props.token, item.id);
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
await load();
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
} catch (error) {
setMessage(error instanceof Error ? error.message : '结算重试失败');
} finally {
@@ -46,22 +94,62 @@ export function BillingSettlementsPanel(props: { token: string }) {
}
}
const exceptions = items.filter((item) => item.status === 'retryable_failed' || item.status === 'manual_review').length;
function toggleItem(itemId: string, checked: boolean) {
setSelectedIds((current) => {
const next = new Set(current);
if (checked) next.add(itemId);
else next.delete(itemId);
return next;
});
}
function toggleVisible(checked: boolean) {
setSelectedIds((current) => {
const next = new Set(current);
visibleRetryableItems.forEach((item) => {
if (checked) next.add(item.id);
else next.delete(item.id);
});
return next;
});
}
async function retrySelected() {
if (!selectedItems.length) return;
setBatchRetrying(true);
setMessage('');
const processingItems = [...selectedItems];
try {
const { failedIds, succeeded } = await retrySettlementBatch(
processingItems,
(item) => retryBillingSettlement(props.token, item.id),
);
setSelectedIds(new Set(failedIds));
await load();
setMessage(failedIds.length
? `批量结算完成:成功 ${succeeded} 条,失败 ${failedIds.length} 条;失败记录已保留选择。`
: `已批量结算 ${succeeded} 条记录。`);
} catch (error) {
setMessage(error instanceof Error ? error.message : '批量结算处理失败');
} finally {
setBatchRetrying(false);
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<div className="identityHeaderTitle">
<div className="iconBox"><ReceiptText size={17} /></div>
<div>
<CardTitle></CardTitle>
<p className="mutedText"></p>
</div>
<Badge variant={exceptions ? 'warning' : 'secondary'}>{exceptions ? `${exceptions} 条异常` : `${items.length}`}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="tableActions">
<section className="pageStack">
<Card className="compactAdminTableCard">
<CardContent className="compactAdminTableContent">
<div className="compactAdminToolbar billingCompactToolbar">
<span className="platformSearchBox compactAdminSearch">
<Search size={15} />
<Input
aria-label="搜索计费结算"
value={query}
placeholder="任务 ID、结算 ID、动作或异常原因"
onChange={(event) => setQuery(event.target.value)}
/>
</span>
<Select size="sm" aria-label="结算状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value=""></option>
<option value="pending"></option>
@@ -70,43 +158,76 @@ export function BillingSettlementsPanel(props: { token: string }) {
<option value="manual_review"></option>
<option value="completed"></option>
</Select>
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
</Button>
<div className="compactAdminToolbarActions">
<Button type="button" size="sm" disabled={!selectedItems.length || busy} onClick={() => setBatchConfirming(true)}>
<RotateCcw size={14} />
{batchRetrying ? '结算中' : `批量结算${selectedItems.length ? `${selectedItems.length}` : ''}`}
</Button>
<Button type="button" size="sm" variant="outline" disabled={loading || busy} onClick={() => void load()}>
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
</Button>
</div>
</div>
{message && <p className="formMessage">{message}</p>}
{filteredItems.length ? (
<div className="billingSettlementTableViewport">
<Table density="compact" className="identityDataTable billingSettlementTable">
<TableRow>
<TableHead>
<Checkbox
aria-label="选择当前筛选结果中的全部可处理结算"
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
disabled={!visibleRetryableItems.length || busy}
onCheckedChange={(checked) => toggleVisible(checked === true)}
/>
</TableHead>
<TableHead> / </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{filteredItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Checkbox
aria-label={`选择结算 ${shortId(item.id)}`}
checked={selectedIds.has(item.id)}
disabled={!canRetry(item) || busy}
onCheckedChange={(checked) => toggleItem(item.id, checked === true)}
/>
</TableCell>
<TableCell>
<span className="identityTableName">
<strong>{shortId(item.taskId)}</strong>
<small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small>
</span>
</TableCell>
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
<TableCell>{item.attempts} / 20</TableCell>
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
<TableCell>
{canRetry(item) ? (
<Button type="button" size="xs" variant="outline" disabled={busy} onClick={() => setConfirming(item)}>
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
</Button>
) : '-'}
</TableCell>
</TableRow>
))}
</Table>
</div>
) : (
<div className="emptyState">
<AlertTriangle size={18} />
<strong>{loading ? '正在加载结算队列' : '暂无匹配的结算记录'}</strong>
<span></span>
</div>
)}
</CardContent>
</Card>
{items.length ? (
<Table density="compact" className="identityDataTable">
<TableRow>
<TableHead> / </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell><span className="identityTableName"><strong>{shortId(item.taskId)}</strong><small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small></span></TableCell>
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
<TableCell>{item.attempts} / 20</TableCell>
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
<TableCell>
{(item.status === 'retryable_failed' || item.status === 'manual_review') ? (
<Button type="button" size="xs" variant="outline" disabled={retrying === item.id} onClick={() => setConfirming(item)}>
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
</Button>
) : '-'}
</TableCell>
</TableRow>
))}
</Table>
) : (
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span></span></CardContent></Card>
)}
<ConfirmDialog
open={Boolean(confirming)}
loading={Boolean(confirming && retrying === confirming.id)}
@@ -120,10 +241,42 @@ export function BillingSettlementsPanel(props: { token: string }) {
void retry(item).finally(() => setConfirming(null));
}}
/>
</div>
<ConfirmDialog
open={batchConfirming}
loading={batchRetrying}
title={`批量结算 ${selectedItems.length} 条记录?`}
description="系统会按记录动作逐条执行结算或释放冻结,并复用现有审计流程;只处理等待重试或人工复核记录,不会重新调用上游。"
confirmLabel="确认批量结算"
onCancel={() => setBatchConfirming(false)}
onConfirm={() => void retrySelected().finally(() => setBatchConfirming(false))}
/>
</section>
);
}
function canRetry(item: BillingSettlement) {
return item.status === 'retryable_failed' || item.status === 'manual_review';
}
export async function retrySettlementBatch<T extends { id: string }>(
items: T[],
retryOne: (item: T) => Promise<unknown>,
concurrency = 5,
) {
const failedIds: string[] = [];
let succeeded = 0;
const batchSize = Math.max(1, Math.floor(concurrency));
for (let index = 0; index < items.length; index += batchSize) {
const batch = items.slice(index, index + batchSize);
const results = await Promise.allSettled(batch.map(retryOne));
results.forEach((result, resultIndex) => {
if (result.status === 'fulfilled') succeeded += 1;
else failedIds.push(batch[resultIndex].id);
});
}
return { failedIds, succeeded };
}
function statusLabel(status: string) {
return ({ pending: '待处理', processing: '处理中', retryable_failed: '等待重试', manual_review: '人工复核', completed: '已完成' } as Record<string, string>)[status] ?? status;
}
@@ -1,8 +1,9 @@
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, ShieldCheck, Trash2, UserRound, UsersRound } from 'lucide-react';
import type {
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayAccessRuleBatchRequest,
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletAccount,
@@ -31,6 +32,19 @@ import {
} from '../../components/ui';
import type { ConsoleData } from '../../app-state';
import type { LoadState } from '../../types';
import { AccessPermissionEditor, countAccessPermissionRules } from './AccessPermissionEditor';
import {
quotaPolicyFromForm,
quotaPolicySummary,
quotaPolicyToForm,
rateLimitPolicyFromForm,
rateLimitPolicySummary,
rateLimitPolicyToForm,
UserGroupQuotaPolicyEditor,
UserGroupRateLimitEditor,
type UserGroupQuotaEntryForm,
type UserGroupRateLimitRuleForm,
} from './UserGroupPolicyEditors';
type TenantForm = {
tenantKey: string;
@@ -83,8 +97,9 @@ type UserGroupForm = {
rechargeDiscountPolicy: Record<string, unknown>;
billingDiscountFactor: string;
billingDiscountPolicy: Record<string, unknown>;
rateLimitPolicyJson: string;
quotaPolicyJson: string;
rateLimitRules: UserGroupRateLimitRuleForm[];
rateLimitPolicyExtra: Record<string, unknown>;
quotaPolicyEntries: UserGroupQuotaEntryForm[];
metadataJson: string;
status: string;
};
@@ -427,6 +442,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
const [form, setForm] = useState<UserGroupForm>(() => defaultUserGroupForm());
const [localError, setLocalError] = useState('');
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
const [permissionGroup, setPermissionGroup] = useState<UserGroup | null>(null);
function openCreateDialog() {
setEditingId('');
@@ -488,20 +504,32 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.data.userGroups.map((group) => (
<TableRow key={group.id}>
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
<TableCell>{group.source}</TableCell>
<TableCell>{group.priority}</TableCell>
<TableCell>{discountSummary(group)}</TableCell>
<TableCell>{policyKeys(group.rateLimitPolicy).join(', ') || '未设置'}</TableCell>
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editGroup(group)} onDelete={() => setPendingDeleteGroup(group)} /></TableCell>
</TableRow>
))}
{props.data.userGroups.map((group) => {
const permissionSummary = countAccessPermissionRules(props.data.accessRules, 'user_group', group.id);
return (
<TableRow key={group.id}>
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
<TableCell>{group.source}</TableCell>
<TableCell>{group.priority}</TableCell>
<TableCell>{discountSummary(group)}</TableCell>
<TableCell><PolicySummary parts={rateLimitPolicySummary(group.rateLimitPolicy)} /></TableCell>
<TableCell><PolicySummary parts={quotaPolicySummary(group.quotaPolicy)} /></TableCell>
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
<TableCell>
<TableActions
permissionSummary={permissionRuleSummary(permissionSummary)}
onPermissions={() => setPermissionGroup(group)}
onEdit={() => editGroup(group)}
onDelete={() => setPendingDeleteGroup(group)}
/>
</TableCell>
</TableRow>
);
})}
</Table>
) : (
<EmptyIdentity title="暂无用户组" description="可先新增默认用户组,用来配置折扣和并发控制。" />
@@ -509,7 +537,7 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
<FormDialog
ariaLabel={editingId ? '编辑用户组' : '新增用户组'}
className="identityDialog"
className="identityDialog userGroupDialog"
eyebrow={editingId ? 'Edit User Group' : 'New User Group'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
@@ -525,10 +553,44 @@ export function UserGroupsPanel(props: IdentityPanelProps) {
<Label className="spanTwo"><Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<Label><Input size="sm" value={form.rechargeDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, rechargeDiscountFactor: event.target.value })} /></Label>
<Label><Input size="sm" value={form.billingDiscountFactor} inputMode="decimal" placeholder="1 = 不打折,0.95 = 95 折" onChange={(event) => setForm({ ...form, billingDiscountFactor: event.target.value })} /></Label>
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
<UserGroupRateLimitEditor value={form.rateLimitRules} onChange={(rateLimitRules) => setForm({ ...form, rateLimitRules })} />
<UserGroupQuotaPolicyEditor value={form.quotaPolicyEntries} onChange={(quotaPolicyEntries) => setForm({ ...form, quotaPolicyEntries })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<FormDialog
ariaLabel={permissionGroup ? `${permissionGroup.name} 模型权限` : '用户组模型权限'}
bodyClassName="userGroupPermissionDialogBody"
className="userGroupPermissionDialog"
eyebrow="Model Permission"
footer={<Button type="button" onClick={() => setPermissionGroup(null)}></Button>}
open={Boolean(permissionGroup)}
title={permissionGroup ? `${permissionGroup.name} · 模型权限` : '模型权限'}
onClose={() => setPermissionGroup(null)}
onSubmit={(event) => {
event.preventDefault();
setPermissionGroup(null);
}}
>
{permissionGroup && (
<>
<div className="accessGroupHint userGroupPermissionHint">
<ShieldCheck size={15} />
<span></span>
</div>
<AccessPermissionEditor
key={permissionGroup.id}
accessRules={props.data.accessRules}
metadataMode="user_group_permission_tree"
platformModels={props.data.models}
platforms={props.data.platforms}
state={props.state}
subjectId={permissionGroup.id}
subjectType="user_group"
onBatchAccessRules={props.onBatchAccessRules}
/>
</>
)}
</FormDialog>
<ConfirmDialog
confirmLabel="删除用户组"
description="删除后租户和用户上引用该用户组的默认策略会失效。"
@@ -553,6 +615,7 @@ type IdentityPanelProps = {
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onRechargeUserWalletBalance: (userId: string, input: WalletRechargeRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
};
const defaultRechargeReason = '手动充值';
@@ -603,10 +666,21 @@ function IdentityName(props: { title: string; subtitle: string }) {
);
}
function TableActions(props: { onDelete: () => void; onEdit: () => void; onWallet?: () => void }) {
function TableActions(props: {
onDelete: () => void;
onEdit: () => void;
onPermissions?: () => void;
onWallet?: () => void;
permissionSummary?: string;
}) {
return (
<span className="tableActions">
{props.onWallet && <Button type="button" variant="outline" size="icon" title="余额" onClick={props.onWallet}><CircleDollarSign size={14} /></Button>}
{props.onPermissions && (
<Button type="button" variant="outline" size="xs" title={props.permissionSummary || '模型权限'} onClick={props.onPermissions}>
<KeyRound size={13} />
</Button>
)}
<Button type="button" variant="outline" size="icon" title="修改" onClick={props.onEdit}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="icon" title="删除" onClick={props.onDelete}><Trash2 size={14} /></Button>
</span>
@@ -792,14 +866,16 @@ function defaultUserGroupForm(): UserGroupForm {
rechargeDiscountPolicy: {},
billingDiscountFactor: '1',
billingDiscountPolicy: {},
rateLimitPolicyJson: '{"rules":[]}',
quotaPolicyJson: '{}',
rateLimitRules: [],
rateLimitPolicyExtra: {},
quotaPolicyEntries: [],
metadataJson: '{}',
status: 'active',
};
}
function userGroupToForm(group: UserGroup): UserGroupForm {
const rateLimitPolicy = rateLimitPolicyToForm(group.rateLimitPolicy);
return {
groupKey: group.groupKey,
name: group.name,
@@ -810,8 +886,9 @@ function userGroupToForm(group: UserGroup): UserGroupForm {
rechargeDiscountPolicy: group.rechargeDiscountPolicy ?? {},
billingDiscountFactor: discountFactorText(group.billingDiscountPolicy),
billingDiscountPolicy: group.billingDiscountPolicy ?? {},
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
quotaPolicyJson: stringifyJson(group.quotaPolicy),
rateLimitRules: rateLimitPolicy.rules,
rateLimitPolicyExtra: rateLimitPolicy.extra,
quotaPolicyEntries: quotaPolicyToForm(group.quotaPolicy),
metadataJson: stringifyJson(group.metadata),
status: group.status,
};
@@ -826,8 +903,8 @@ function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest {
priority: Number(form.priority) || 100,
rechargeDiscountPolicy: discountPolicyPayload(form.rechargeDiscountPolicy, form.rechargeDiscountFactor, '充值折扣系数'),
billingDiscountPolicy: discountPolicyPayload(form.billingDiscountPolicy, form.billingDiscountFactor, '计费折扣系数'),
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
rateLimitPolicy: rateLimitPolicyFromForm(form.rateLimitRules, form.rateLimitPolicyExtra),
quotaPolicy: quotaPolicyFromForm(form.quotaPolicyEntries),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
@@ -928,9 +1005,21 @@ function trimNumber(value: number) {
return value.toFixed(6).replace(/\.?0+$/, '');
}
function policyKeys(value?: Record<string, unknown>) {
if (!value) return [];
return Object.keys(value).slice(0, 3);
function PolicySummary(props: { parts: string[] }) {
if (!props.parts.length) return <span className="mutedText"></span>;
return (
<span className="identityTableName userGroupPolicySummary">
<strong title={props.parts.join(' / ')}>{props.parts.slice(0, 2).join(' · ')}</strong>
{props.parts.length > 2 && <small>{props.parts.slice(2).join(' · ')}</small>}
</span>
);
}
function permissionRuleSummary(summary: ReturnType<typeof countAccessPermissionRules>) {
const allow = summary.allow.platforms + summary.allow.models;
const deny = summary.deny.platforms + summary.deny.models;
if (!allow && !deny) return '模型权限:未配置,默认放行';
return `模型权限:专属 ${allow} 条,拒绝 ${deny}`;
}
function stringifyJson(value: unknown) {
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
import { filterAdminPlatforms } from './PlatformManagementPanel';
const platforms = [
{ id: 'platform-a', name: 'OpenAI Official', internalName: '主账号', platformKey: 'openai-main', provider: 'openai', baseUrl: 'https://api.openai.com', status: 'enabled' },
{ id: 'platform-b', name: 'Volces', platformKey: 'volces', provider: 'volces', baseUrl: 'https://ark.example.com', status: 'disabled' },
] as IntegrationPlatform[];
const models = [
{ id: 'model-a', platformId: 'platform-a', modelName: 'gpt-image-2', providerModelName: 'gpt-image-2', modelAlias: 'image-pro', displayName: 'GPT Image 2', modelType: ['image_generate'], enabled: true },
{ id: 'model-b', platformId: 'platform-b', modelName: 'seedance', providerModelName: 'seedance-v2', displayName: 'Seedance', modelType: ['video_generate'], enabled: true },
] as PlatformModel[];
describe('filterAdminPlatforms', () => {
it('matches a platform through its bound model name and alias', () => {
expect(filterAdminPlatforms(platforms, models, {
query: 'image-pro',
provider: '',
status: '',
now: Date.now(),
}).map((platform) => platform.id)).toEqual(['platform-a']);
});
it('combines provider and runtime status filters', () => {
expect(filterAdminPlatforms(platforms, models, {
query: '',
provider: 'volces',
status: 'disabled',
now: Date.now(),
}).map((platform) => platform.id)).toEqual(['platform-b']);
});
});
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Switch, Table, TableCell, TableHead, TableRow } from '../../components/ui';
import { Badge, Button, Card, CardContent, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Switch, Table, TableCell, TableHead, TableRow } from '../../components/ui';
import type { LoadState, PlatformWithModelsInput } from '../../types';
import {
authTypes,
@@ -40,8 +40,14 @@ export function PlatformManagementPanel(props: {
const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? '';
const [now, setNow] = useState(() => Date.now());
const [dialogOpen, setDialogOpen] = useState(false);
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const [viewMode, setViewMode] = useState<'platforms' | 'models'>('platforms');
const [platformQuery, setPlatformQuery] = useState('');
const [platformProvider, setPlatformProvider] = useState('');
const [platformStatus, setPlatformStatus] = useState('');
const [modelQuery, setModelQuery] = useState('');
const [modelType, setModelType] = useState('');
const [modelStatus, setModelStatus] = useState('');
const [selectedPlatformId, setSelectedPlatformId] = useState('');
const [validationMessage, setValidationMessage] = useState('');
const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false);
@@ -62,12 +68,36 @@ export function PlatformManagementPanel(props: {
const availableModels = useMemo(() => props.baseModels.filter((item) => item.status !== 'hidden'), [props.baseModels]);
const selectedModels = useMemo(() => selectedModelsForForm(props.baseModels, form), [form, props.baseModels]);
const platformModelCount = useMemo(() => countModelsByPlatform(props.platformModels), [props.platformModels]);
const selectedModelPlatformId = selectedPlatformId || props.platforms[0]?.id || '';
const selectedModelPlatformId = selectedPlatformId;
const platformFilterActive = Boolean(platformQuery || platformProvider || platformStatus);
const modelFilterActive = Boolean(selectedModelPlatformId || modelQuery || modelType || modelStatus);
const currentFilterActive = viewMode === 'platforms' ? platformFilterActive : modelFilterActive;
const currentAdvancedFilterCount = viewMode === 'platforms'
? [platformProvider, platformStatus].filter(Boolean).length
: [selectedModelPlatformId, modelType, modelStatus].filter(Boolean).length;
const platformProviderOptions = useMemo(
() => Array.from(new Set(props.platforms.map((platform) => platform.provider).filter(Boolean))).sort((a, b) => a.localeCompare(b)),
[props.platforms],
);
const modelTypeOptions = useMemo(
() => Array.from(new Set(props.platformModels.flatMap((model) => model.modelType))).sort((a, b) => a.localeCompare(b)),
[props.platformModels],
);
const filteredPlatforms = useMemo(
() => filterAdminPlatforms(props.platforms, props.platformModels, {
query: platformQuery,
provider: platformProvider,
status: platformStatus,
now,
}),
[now, platformProvider, platformQuery, platformStatus, props.platformModels, props.platforms],
);
const filteredPlatformModels = useMemo(() => {
const keyword = modelQuery.trim().toLowerCase();
return props.platformModels.filter((model) => {
const matchesPlatform = !selectedModelPlatformId || model.platformId === selectedModelPlatformId;
const platform = platformMap.get(model.platformId);
const cooldown = cooldownRemainingMs(model.cooldownUntil, now) > 0 || cooldownRemainingMs(platform?.cooldownUntil, now) > 0;
const text = [
model.displayName,
model.modelName,
@@ -79,9 +109,14 @@ export function PlatformManagementPanel(props: {
platform?.internalName,
platform?.platformKey,
].filter(Boolean).join(' ').toLowerCase();
return matchesPlatform && (!keyword || text.includes(keyword));
const matchesStatus = !modelStatus ||
(modelStatus === 'cooldown' ? cooldown : modelStatus === 'enabled' ? model.enabled : !model.enabled);
return matchesPlatform &&
(!keyword || text.includes(keyword)) &&
(!modelType || model.modelType.includes(modelType)) &&
matchesStatus;
});
}, [modelQuery, platformMap, props.platformModels, selectedModelPlatformId]);
}, [modelQuery, modelStatus, modelType, now, platformMap, props.platformModels, selectedModelPlatformId]);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
@@ -179,56 +214,172 @@ export function PlatformManagementPanel(props: {
}
}
function resetCurrentFilters() {
if (viewMode === 'platforms') {
setPlatformQuery('');
setPlatformProvider('');
setPlatformStatus('');
return;
}
setSelectedPlatformId('');
setModelQuery('');
setModelType('');
setModelStatus('');
}
return (
<section className="pageStack">
<ScreenMessage message={validationMessage} variant="error" onClose={() => setValidationMessage('')} />
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">Base URL</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</CardHeader>
<CardContent>
<div className="platformViewTabs">
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}></button>
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}></button>
<Card className="compactAdminTableCard">
<CardContent className="compactAdminTableContent">
<div className="compactAdminToolbar platformCompactToolbar">
<div className="platformViewTabs">
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}></button>
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}></button>
</div>
<span className="platformSearchBox compactAdminSearch">
<Search size={15} />
<Input
aria-label={viewMode === 'platforms' ? '搜索平台' : '搜索模型'}
value={viewMode === 'platforms' ? platformQuery : modelQuery}
placeholder={viewMode === 'platforms' ? '平台名、Key、Provider、Base URL 或绑定模型' : '模型名称、别名、类型或平台名称'}
onChange={(event) => {
if (viewMode === 'platforms') setPlatformQuery(event.target.value);
else setModelQuery(event.target.value);
}}
/>
</span>
<div className="compactAdminToolbarActions">
<Button type="button" variant="outline" size="sm" onClick={() => setFilterDialogOpen(true)}>
<SlidersHorizontal size={14} />
{currentAdvancedFilterCount ? `${currentAdvancedFilterCount}` : ''}
</Button>
<Button type="button" variant="outline" size="sm" disabled={!currentFilterActive} onClick={resetCurrentFilters}>
<RotateCcw size={14} />
</Button>
<Button type="button" size="sm" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
</div>
{viewMode === 'platforms' ? (
<PlatformTable
now={now}
platformModelCount={platformModelCount}
platforms={props.platforms}
providerMap={providerMap}
pricingRuleSets={props.pricingRuleSets}
togglingPlatformId={togglingPlatformId}
onDelete={setPendingDeletePlatform}
onCreate={openCreateDialog}
onEdit={openEditDialog}
onToggleStatus={togglePlatformStatus}
/>
<section className="platformFilteredView">
<PlatformTable
filtered={platformFilterActive}
now={now}
platformModelCount={platformModelCount}
platforms={filteredPlatforms}
providerMap={providerMap}
pricingRuleSets={props.pricingRuleSets}
togglingPlatformId={togglingPlatformId}
onDelete={setPendingDeletePlatform}
onCreate={openCreateDialog}
onEdit={openEditDialog}
onToggleStatus={togglePlatformStatus}
/>
</section>
) : (
<PlatformModelTable
baseModels={props.baseModels}
modelQuery={modelQuery}
filterActive={modelFilterActive}
models={filteredPlatformModels}
platformId={selectedModelPlatformId}
platformMap={platformMap}
platforms={props.platforms}
providerMap={providerMap}
onModelQueryChange={setModelQuery}
now={now}
onPlatformChange={setSelectedPlatformId}
/>
)}
{props.message && <p className="formMessage">{props.message}</p>}
</CardContent>
</Card>
<FormDialog
ariaLabel="平台管理高级筛选"
className="compactFilterDialog"
closeLabel="关闭"
footer={(
<>
<Button
type="button"
variant="outline"
disabled={!currentAdvancedFilterCount}
onClick={() => {
if (viewMode === 'platforms') {
setPlatformProvider('');
setPlatformStatus('');
} else {
setSelectedPlatformId('');
setModelType('');
setModelStatus('');
}
}}
>
<RotateCcw size={15} />
</Button>
<Button type="submit"></Button>
</>
)}
open={filterDialogOpen}
title={viewMode === 'platforms' ? '平台高级筛选' : '模型高级筛选'}
onClose={() => setFilterDialogOpen(false)}
onSubmit={(event) => {
event.preventDefault();
setFilterDialogOpen(false);
}}
>
{viewMode === 'platforms' ? (
<>
<Label>
Provider
<Select value={platformProvider || 'all'} onChange={(event) => setPlatformProvider(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"> Provider</option>
{platformProviderOptions.map((provider) => <option value={provider} key={provider}>{providerMap.get(provider)?.displayName ?? provider}</option>)}
</Select>
</Label>
<Label>
<Select value={platformStatus || 'all'} onChange={(event) => setPlatformStatus(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"></option>
<option value="enabled"></option>
<option value="disabled"></option>
<option value="cooldown"></option>
</Select>
</Label>
</>
) : (
<>
<Label>
<Select value={selectedModelPlatformId} onChange={(event) => setSelectedPlatformId(event.target.value)}>
<option value=""></option>
{props.platforms.map((platform) => (
<option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>
))}
</Select>
</Label>
<Label>
<Select value={modelType || 'all'} onChange={(event) => setModelType(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"></option>
{modelTypeOptions.map((type) => <option value={type} key={type}>{type}</option>)}
</Select>
</Label>
<Label>
<Select value={modelStatus || 'all'} onChange={(event) => setModelStatus(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"></option>
<option value="enabled"></option>
<option value="disabled"></option>
<option value="cooldown"></option>
</Select>
</Label>
</>
)}
</FormDialog>
<FormDialog
bodyClassName="platformDialogBody"
className="platformDialog"
@@ -423,6 +574,7 @@ function ModelBindingPolicy(props: { form: PlatformWizardForm; onChange: (value:
}
function PlatformTable(props: {
filtered: boolean;
now: number;
platformModelCount: Map<string, number>;
platforms: IntegrationPlatform[];
@@ -435,6 +587,9 @@ function PlatformTable(props: {
onToggleStatus: (platform: IntegrationPlatform, status: 'enabled' | 'disabled') => void;
}) {
if (!props.platforms.length) {
if (props.filtered) {
return <EmptyState title="没有匹配的平台" description="调整平台关键词、Provider 或状态筛选后再试。" />;
}
return (
<div className="platformEmptyState">
<div className="platformEmptyIcon"><ServerCog size={24} /></div>
@@ -448,7 +603,7 @@ function PlatformTable(props: {
);
}
return (
<Table className="platformDataTable">
<Table className="shTableViewport platformDataTable platformManagementTableViewport">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead>Provider</TableHead>
@@ -457,12 +612,14 @@ function PlatformTable(props: {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.platforms.map((platform) => {
const pricing = platformPricingSummary(platform, props.pricingRuleSets);
const rateLimit = platformRateLimitSummary(platform.rateLimitPolicy);
const runtime = platformRuntimeSummary(platform);
const runtimeDescription = platformRuntimeDescription(platform);
const platformCooldownMs = cooldownRemainingMs(platform.cooldownUntil, props.now);
const isEnabled = platform.status === 'enabled';
const isToggling = props.togglingPlatformId === platform.id;
@@ -512,7 +669,20 @@ function PlatformTable(props: {
<Badge variant={isEnabled ? 'success' : 'destructive'}>{isEnabled ? '已启用' : '已禁用'}</Badge>
)}
</strong>
<small>{platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime}</small>
{platformCooldownMs > 0 && <small> {formatCooldownRemaining(platformCooldownMs)}</small>}
</span>
</TableCell>
<TableCell>
<span className="platformTableName">
<strong>{platform.priority}</strong>
{platform.effectivePriority !== undefined && platform.effectivePriority !== platform.priority && (
<small> {platform.effectivePriority}</small>
)}
</span>
</TableCell>
<TableCell>
<span className="platformTableName">
<strong title={runtimeDescription}>{runtimeDescription}</strong>
</span>
</TableCell>
<TableCell>
@@ -534,36 +704,15 @@ function PlatformTable(props: {
function PlatformModelTable(props: {
baseModels: BaseModelCatalogItem[];
modelQuery: string;
filterActive: boolean;
models: PlatformModel[];
platformId: string;
platformMap: Map<string, IntegrationPlatform>;
platforms: IntegrationPlatform[];
providerMap: Map<string, CatalogProvider>;
now: number;
onModelQueryChange: (value: string) => void;
onPlatformChange: (value: string) => void;
}) {
return (
<section className="platformModelView">
<div className="platformModelToolbar">
<Label>
<Select value={props.platformId} onChange={(event) => props.onPlatformChange(event.target.value)}>
{props.platforms.map((platform) => (
<option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>
))}
</Select>
</Label>
<Label>
<Input
value={props.modelQuery}
placeholder="模型名称、别名、类型或平台名称"
onChange={(event) => props.onModelQueryChange(event.target.value)}
/>
</Label>
</div>
{!props.platforms.length ? (
<EmptyState title="暂无平台" description="请先添加平台,再查看对应平台模型。" />
) : (
@@ -1213,6 +1362,42 @@ function cooldownRemainingMs(cooldownUntil: string | undefined, now: number) {
return Math.max(until - now, 0);
}
export function filterAdminPlatforms(
platforms: IntegrationPlatform[],
models: PlatformModel[],
options: { query: string; provider: string; status: string; now: number },
) {
const searchByPlatform = new Map<string, string[]>();
models.forEach((model) => {
const values = searchByPlatform.get(model.platformId) ?? [];
values.push(...[
model.displayName,
model.modelName,
model.providerModelName,
model.modelAlias,
...model.modelType,
].filter((value): value is string => Boolean(value)));
searchByPlatform.set(model.platformId, values);
});
const keyword = options.query.trim().toLowerCase();
return platforms.filter((platform) => {
const cooldown = cooldownRemainingMs(platform.cooldownUntil, options.now) > 0;
const text = [
platform.name,
platform.internalName,
platform.platformKey,
platform.provider,
platform.baseUrl,
...(searchByPlatform.get(platform.id) ?? []),
].filter(Boolean).join(' ').toLowerCase();
const matchesStatus = !options.status ||
(options.status === 'cooldown' ? cooldown : platform.status === options.status);
return (!keyword || text.includes(keyword)) &&
(!options.provider || platform.provider === options.provider) &&
matchesStatus;
});
}
function formatCooldownRemaining(milliseconds: number) {
const minutes = milliseconds / 60000;
if (minutes >= 1) return `${trimNumber(Math.ceil(minutes * 10) / 10)} 分钟`;
@@ -1220,11 +1405,11 @@ function formatCooldownRemaining(milliseconds: number) {
return `${Math.max(seconds, 1)}`;
}
function platformRuntimeSummary(platform: IntegrationPlatform) {
function platformRuntimeDescription(platform: IntegrationPlatform) {
const retryPolicy = platform.retryPolicy ?? {};
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2;
return `优先级 ${platform.priority} · ${retryEnabled ? `同平台最多尝试 ${maxAttempts}` : '同平台不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`;
return `${retryEnabled ? `同平台最多尝试 ${maxAttempts}` : '同平台不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`;
}
function proxyModeText(mode: PlatformWizardForm['proxyMode']) {
+65 -59
View File
@@ -1,7 +1,7 @@
import { useMemo, useState, type FormEvent } from 'react';
import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react';
import { FileText, Image, Pencil, Plus, RotateCcw, Search, Trash2, Video } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
import { Badge, Button, Card, CardContent, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { unitLabel } from './PricingEditorControls';
import { createDefaultPricingRules, KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
@@ -92,68 +92,74 @@ export function PricingRulesPanel(props: {
return (
<div className="pageStack">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<Badge variant="secondary">{props.pricingRuleSets.length}</Badge>
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p></p>
<Button type="button" onClick={openCreateDialog}><Plus size={15} /></Button>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索规则名称 / key" />
<Card className="compactAdminTableCard">
<CardContent className="compactAdminTableContent">
<div className="compactAdminToolbar catalogCompactToolbar">
<span className="platformSearchBox compactAdminSearch">
<Search size={15} />
<Input
aria-label="搜索定价规则"
value={query}
placeholder="规则名称、Key 或说明"
onChange={(event) => setQuery(event.target.value)}
/>
</span>
<div className="compactAdminToolbarActions">
<Button type="button" variant="outline" size="sm" disabled={!query} onClick={() => setQuery('')}>
<RotateCcw size={14} />
</Button>
<Button type="button" size="sm" onClick={openCreateDialog}><Plus size={15} /></Button>
</div>
</div>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
<section className="pricingRuleGrid">
{filtered.map((ruleSet) => (
<article className="pricingRuleCard" key={ruleSet.id}>
<header>
<div>
<strong>{ruleSet.name}</strong>
<span>{ruleSet.ruleSetKey}</span>
</div>
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
</header>
<p>{ruleSet.description || '未填写说明'}</p>
<div className="providerCatalogMeta">
<span>{ruleSet.category}</span>
<span>{ruleSet.currency}</span>
<span>{pricingRuleSummaries(ruleSet).length} </span>
</div>
<div className="pricingRuleItems">
{pricingRuleSummaries(ruleSet).map((item) => (
<span key={item.label}>
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
<em>{item.value}</em>
</span>
))}
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} /></Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultRuleSet(ruleSet)}
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
onClick={() => setPendingDeleteRuleSet(ruleSet)}
>
<Trash2 size={14} />
</Button>
</div>
</article>
))}
{!filtered.length && (
<div className="emptyState"><strong></strong></div>
)}
</section>
</CardContent>
</Card>
<section className="pricingRuleGrid">
{filtered.map((ruleSet) => (
<article className="pricingRuleCard" key={ruleSet.id}>
<header>
<div>
<strong>{ruleSet.name}</strong>
<span>{ruleSet.ruleSetKey}</span>
</div>
<Badge variant={ruleSet.status === 'active' ? 'success' : 'secondary'}>{ruleSet.status}</Badge>
</header>
<p>{ruleSet.description || '未填写说明'}</p>
<div className="providerCatalogMeta">
<span>{ruleSet.category}</span>
<span>{ruleSet.currency}</span>
<span>{pricingRuleSummaries(ruleSet).length} </span>
</div>
<div className="pricingRuleItems">
{pricingRuleSummaries(ruleSet).map((item) => (
<span key={item.label}>
<strong>{pricingRuleSummaryIcon(item.kind)}{item.label}</strong>
<em>{item.value}</em>
</span>
))}
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} /></Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultRuleSet(ruleSet)}
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
onClick={() => setPendingDeleteRuleSet(ruleSet)}
>
<Trash2 size={14} />
</Button>
</div>
</article>
))}
{!filtered.length && (
<Card><CardContent className="emptyState"><strong></strong></CardContent></Card>
)}
</section>
<FormDialog
ariaLabel={editingId ? '编辑定价规则' : '新增定价规则'}
bodyClassName="pricingRuleFormBody"
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import type { IntegrationPlatform, ModelRateLimitStatus } from '@easyai-ai-gateway/contracts';
import { filterAndSortRealtimeLoad } from './RealtimeLoadPanel';
function status(input: Partial<ModelRateLimitStatus> & Pick<ModelRateLimitStatus, 'platformModelId' | 'platformId' | 'displayName'>): ModelRateLimitStatus {
const { displayName, ...overrides } = input;
return {
platformName: input.platformId,
provider: 'provider',
platformStatus: 'enabled',
platformPriority: 10,
platformEffectivePriority: 10,
modelName: displayName,
displayName,
modelType: ['image_generate'],
enabled: true,
concurrent: { currentValue: 0, usedValue: 0, reservedValue: 0, limitValue: 10, limited: true, ratio: 0 },
queuedTasks: 0,
rpm: { currentValue: 0, usedValue: 0, reservedValue: 0, limitValue: 100, limited: true, ratio: 0 },
tpm: { currentValue: 0, usedValue: 0, reservedValue: 0, limitValue: 1000, limited: true, ratio: 0 },
loadRatio: 0,
...overrides,
};
}
describe('filterAndSortRealtimeLoad', () => {
const platforms = new Map<string, IntegrationPlatform>([
['platform-a', { id: 'platform-a', name: 'Alpha', internalName: '主平台', platformKey: 'alpha', provider: 'provider', status: 'enabled' } as IntegrationPlatform],
['platform-b', { id: 'platform-b', name: 'Beta', platformKey: 'beta', provider: 'provider', status: 'disabled' } as IntegrationPlatform],
]);
const statuses = [
status({ platformModelId: 'model-a', platformId: 'platform-a', displayName: 'Image A', loadRatio: 0.4, queuedTasks: 2 }),
status({ platformModelId: 'model-b', platformId: 'platform-b', displayName: 'Image B', loadRatio: 0.9, enabled: false, platformStatus: 'disabled' }),
];
it('searches platform aliases and sorts by load descending', () => {
const result = filterAndSortRealtimeLoad(statuses, platforms, {
query: '平台',
platformId: '',
modelType: '',
runtimeState: '',
sortField: 'load',
sortDirection: 'desc',
now: Date.now(),
});
expect(result.map((item) => item.platformModelId)).toEqual(['model-a']);
});
it('filters queued and attention states without changing source order', () => {
const queued = filterAndSortRealtimeLoad(statuses, platforms, {
query: '',
platformId: '',
modelType: 'image_generate',
runtimeState: 'queued',
sortField: 'queued',
sortDirection: 'desc',
now: Date.now(),
});
const attention = filterAndSortRealtimeLoad(statuses, platforms, {
query: '',
platformId: '',
modelType: '',
runtimeState: 'attention',
sortField: 'load',
sortDirection: 'desc',
now: Date.now(),
});
expect(queued.map((item) => item.platformModelId)).toEqual(['model-a']);
expect(attention.map((item) => item.platformModelId)).toEqual(['model-b']);
expect(statuses.map((item) => item.platformModelId)).toEqual(['model-a', 'model-b']);
});
});
+205 -24
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Popover as AntPopover } from 'antd';
import { CheckCircle2, Gauge, History, RotateCcw, SlidersHorizontal } from 'lucide-react';
import { CheckCircle2, History, RotateCcw, Search, SlidersHorizontal } from 'lucide-react';
import type { IntegrationPlatform, ModelRateLimitStatus, PlatformDynamicPriorityUpdateRequest, PlatformPolicyEvent, PriorityDemotionRecord } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow } from '../../components/ui';
import { Badge, Button, Card, CardContent, EmptyState, FormDialog, Input, Label, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
export function RealtimeLoadPanel(props: {
modelRateLimits: ModelRateLimitStatus[];
@@ -16,7 +16,37 @@ export function RealtimeLoadPanel(props: {
const [priorityError, setPriorityError] = useState('');
const [prioritySaving, setPrioritySaving] = useState(false);
const [restoreSavingId, setRestoreSavingId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [platformId, setPlatformId] = useState('');
const [modelType, setModelType] = useState('');
const [runtimeState, setRuntimeState] = useState('');
const [sortField, setSortField] = useState<RealtimeLoadSortField>('load');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]);
const modelTypeOptions = useMemo(
() => Array.from(new Set(props.modelRateLimits.flatMap((status) => status.modelType))).sort((a, b) => a.localeCompare(b)),
[props.modelRateLimits],
);
const filteredStatuses = useMemo(
() => filterAndSortRealtimeLoad(props.modelRateLimits, platformMap, {
query,
platformId,
modelType,
runtimeState,
sortField,
sortDirection,
now,
}),
[modelType, now, platformId, platformMap, props.modelRateLimits, query, runtimeState, sortDirection, sortField],
);
const hasActiveFilters = Boolean(query || platformId || modelType || runtimeState);
const advancedFilterCount = [
modelType,
runtimeState,
sortDirection === 'desc' ? '' : sortDirection,
].filter(Boolean).length;
const hasActiveSettings = Boolean(query || platformId || advancedFilterCount || sortField !== 'load');
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
@@ -78,27 +108,126 @@ export function RealtimeLoadPanel(props: {
}
}
function resetFilters() {
setQuery('');
setPlatformId('');
setModelType('');
setRuntimeState('');
setSortField('load');
setSortDirection('desc');
}
return (
<section className="pageStack">
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText"> RPMTPM</p>
<Card className="compactAdminTableCard">
<CardContent className="compactAdminTableContent">
<div className="compactAdminToolbar realtimeCompactToolbar">
<span className="platformSearchBox compactAdminSearch">
<Search size={15} />
<Input
aria-label="搜索实时负载"
value={query}
placeholder="平台、模型、Provider 或模型类型"
onChange={(event) => setQuery(event.target.value)}
/>
</span>
<Select
aria-label="平台筛选"
value={platformId || 'all'}
onChange={(event) => setPlatformId(event.target.value === 'all' ? '' : event.target.value)}
>
<option value="all"></option>
{props.platforms.map((platform) => <option value={platform.id} key={platform.id}>{platformDisplayName(platform)}</option>)}
</Select>
<Select
aria-label="实时负载排序"
value={sortField}
onChange={(event) => setSortField(event.target.value as RealtimeLoadSortField)}
>
<option value="load"></option>
<option value="queued"></option>
<option value="concurrent"></option>
<option value="tpm">TPM</option>
<option value="rpm">RPM</option>
<option value="priority"></option>
</Select>
<div className="compactAdminToolbarActions">
<Button type="button" variant="outline" size="sm" onClick={() => setFilterDialogOpen(true)}>
<SlidersHorizontal size={14} />
{advancedFilterCount ? `${advancedFilterCount}` : ''}
</Button>
<Button type="button" variant="outline" size="sm" disabled={!hasActiveSettings} onClick={resetFilters}>
<RotateCcw size={14} />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<RateLimitStatusTable
filtered={hasActiveFilters}
now={now}
platformMap={platformMap}
statuses={props.modelRateLimits}
updatedAt={props.modelRateLimitsUpdatedAt}
statuses={filteredStatuses}
onAdjustPriority={openPriorityDialog}
onRestoreRuntimeModel={restoreRuntimeModel}
restoreSavingId={restoreSavingId}
/>
</CardContent>
</Card>
<FormDialog
ariaLabel="实时负载高级筛选"
className="compactFilterDialog"
closeLabel="关闭"
footer={(
<>
<Button
type="button"
variant="outline"
disabled={!advancedFilterCount}
onClick={() => {
setModelType('');
setRuntimeState('');
setSortDirection('desc');
}}
>
<RotateCcw size={15} />
</Button>
<Button type="submit"></Button>
</>
)}
open={filterDialogOpen}
title="实时负载高级筛选"
onClose={() => setFilterDialogOpen(false)}
onSubmit={(event) => {
event.preventDefault();
setFilterDialogOpen(false);
}}
>
<Label>
<Select value={modelType || 'all'} onChange={(event) => setModelType(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"></option>
{modelTypeOptions.map((type) => <option value={type} key={type}>{type}</option>)}
</Select>
</Label>
<Label>
<Select value={runtimeState || 'all'} onChange={(event) => setRuntimeState(event.target.value === 'all' ? '' : event.target.value)}>
<option value="all"></option>
<option value="normal"></option>
<option value="high"> 80%</option>
<option value="queued"></option>
<option value="attention"> / </option>
</Select>
</Label>
<Label>
<Select value={sortDirection} onChange={(event) => setSortDirection(event.target.value as 'asc' | 'desc')}>
<option value="desc"></option>
<option value="asc"></option>
</Select>
</Label>
</FormDialog>
<PlatformPriorityDialog
dialog={priorityDialog}
error={priorityError}
@@ -118,26 +247,27 @@ type PriorityDialogState = {
value: string;
};
type RealtimeLoadSortField = 'load' | 'queued' | 'concurrent' | 'tpm' | 'rpm' | 'priority';
function RateLimitStatusTable(props: {
filtered: boolean;
statuses: ModelRateLimitStatus[];
platformMap: Map<string, IntegrationPlatform>;
now: number;
updatedAt: number | null;
onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void;
onRestoreRuntimeModel: (platformModelId: string) => Promise<void>;
restoreSavingId: string | null;
}) {
if (!props.statuses.length) {
if (props.filtered) {
return <EmptyState title="没有匹配的实时负载" description="调整平台、模型、运行状态或关键词后再试。" />;
}
return <EmptyState title="暂无实时负载" description="模型产生请求后会在这里显示实时 RPM、TPM 和并发窗口。" />;
}
return (
<section className="platformLimitView">
<div className="platformLimitHeader">
<span><Gauge size={15} /></span>
<small> 3 TPM + {formatTimeOfDay(props.updatedAt)}</small>
</div>
<div className="platformLimitTableViewport">
<Table className="platformDataTable platformLimitTable">
<Table className="platformDataTable platformLimitTable" density="compact">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead></TableHead>
@@ -195,6 +325,64 @@ function RateLimitStatusTable(props: {
);
}
export function filterAndSortRealtimeLoad(
statuses: ModelRateLimitStatus[],
platformMap: Map<string, IntegrationPlatform>,
options: {
query: string;
platformId: string;
modelType: string;
runtimeState: string;
sortField: RealtimeLoadSortField;
sortDirection: 'asc' | 'desc';
now: number;
},
) {
const keyword = options.query.trim().toLowerCase();
const filtered = statuses.filter((status) => {
const platform = platformMap.get(status.platformId);
const text = [
status.displayName,
status.modelName,
status.providerModelName,
status.modelAlias,
status.provider,
status.platformName,
platform?.name,
platform?.internalName,
platform?.platformKey,
...status.modelType,
].filter(Boolean).join(' ').toLowerCase();
const cooling = cooldownRemainingMs(status.modelCooldownUntil, options.now) > 0 ||
cooldownRemainingMs(status.platformCooldownUntil, options.now) > 0;
const disabled = !status.enabled || status.platformStatus !== 'enabled';
const matchesRuntime = !options.runtimeState ||
(options.runtimeState === 'high' && status.loadRatio >= 0.8) ||
(options.runtimeState === 'queued' && status.queuedTasks > 0) ||
(options.runtimeState === 'attention' && (cooling || disabled)) ||
(options.runtimeState === 'normal' && !cooling && !disabled && status.loadRatio < 0.8 && status.queuedTasks <= 0);
return (!keyword || text.includes(keyword)) &&
(!options.platformId || status.platformId === options.platformId) &&
(!options.modelType || status.modelType.includes(options.modelType)) &&
matchesRuntime;
});
const direction = options.sortDirection === 'asc' ? 1 : -1;
return filtered.sort((left, right) => {
const difference = realtimeLoadSortValue(left, options.sortField) - realtimeLoadSortValue(right, options.sortField);
if (difference !== 0) return difference * direction;
return (left.displayName || left.modelName).localeCompare(right.displayName || right.modelName);
});
}
function realtimeLoadSortValue(status: ModelRateLimitStatus, field: RealtimeLoadSortField) {
if (field === 'queued') return status.queuedTasks;
if (field === 'concurrent') return status.concurrent.currentValue;
if (field === 'tpm') return status.tpm.currentValue;
if (field === 'rpm') return status.rpm.currentValue;
if (field === 'priority') return status.platformEffectivePriority;
return status.loadRatio;
}
function platformDisplayName(platform: IntegrationPlatform) {
return platform.internalName?.trim() || platform.name;
}
@@ -452,13 +640,6 @@ function reservedMetricText(metric: ModelRateLimitStatus['rpm']) {
return `已结算 ${formatLimit(metric.usedValue)} + 预占 ${formatLimit(metric.reservedValue)}`;
}
function formatTimeOfDay(timestamp: number | null) {
if (!timestamp) return '暂无';
const date = new Date(timestamp);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function formatDateTime(value: string | undefined) {
if (!value) return '';
const date = new Date(value);
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
quotaPolicyFromForm,
quotaPolicySummary,
quotaPolicyToForm,
rateLimitPolicyFromForm,
rateLimitPolicySummary,
rateLimitPolicyToForm,
} from './UserGroupPolicyEditors';
describe('user group policy editors', () => {
it('round-trips canonical rate limits and preserves unknown top-level fields', () => {
const source = {
strategy: 'strict',
rules: [
{ metric: 'rpm' as const, limit: 60, windowSeconds: 60 },
{ metric: 'concurrent' as const, limit: 3, leaseTtlSeconds: 120 },
],
};
const form = rateLimitPolicyToForm(source);
expect(rateLimitPolicyFromForm(form.rules, form.extra)).toEqual(source);
expect(rateLimitPolicySummary(source)).toEqual(['RPM 60', '并发 3']);
});
it('rejects duplicate rate-limit metrics', () => {
const form = rateLimitPolicyToForm({
rules: [
{ metric: 'rpm', limit: 60 },
{ metric: 'rpm', limit: 120 },
],
});
expect(() => rateLimitPolicyFromForm(form.rules, form.extra)).toThrow('不能重复');
});
it('round-trips quota values without changing their types', () => {
const source = {
monthlyResource: 10000,
plan: 'vip',
enabled: true,
dimensions: { image: 20 },
};
const form = quotaPolicyToForm(source);
expect(quotaPolicyFromForm(form)).toEqual(source);
expect(quotaPolicySummary(source)).toEqual([
'monthlyResource 10000',
'plan vip',
'enabled true',
]);
});
});
@@ -0,0 +1,368 @@
import { Plus, Trash2 } from 'lucide-react';
import type { RateLimitMetric, RateLimitPolicy } from '@easyai-ai-gateway/contracts';
import { Button, Input, Select } from '../../components/ui';
export type UserGroupRateLimitRuleForm = {
id: string;
metric: RateLimitMetric;
limit: string;
windowSeconds: string;
leaseTtlSeconds: string;
consume: string;
extra: Record<string, unknown>;
};
export type UserGroupQuotaEntryForm = {
id: string;
key: string;
valueType: 'number' | 'string' | 'boolean' | 'json';
value: string;
};
const rateLimitMetrics: Array<{ value: RateLimitMetric; label: string }> = [
{ value: 'rpm', label: 'RPM / 每分钟请求' },
{ value: 'rps', label: 'RPS / 每秒请求' },
{ value: 'tpm_total', label: 'TPM / 总 Token' },
{ value: 'tpm_input', label: '输入 TPM' },
{ value: 'tpm_output', label: '输出 TPM' },
{ value: 'concurrent', label: '并发请求' },
{ value: 'queue_size', label: '排队数量' },
];
let policyEditorRowSequence = 0;
export function UserGroupRateLimitEditor(props: {
value: UserGroupRateLimitRuleForm[];
onChange: (value: UserGroupRateLimitRuleForm[]) => void;
}) {
function update(id: string, patch: Partial<UserGroupRateLimitRuleForm>) {
props.onChange(props.value.map((item) => item.id === id ? { ...item, ...patch } : item));
}
return (
<section className="userGroupPolicyEditor spanTwo">
<header className="userGroupPolicyHeader">
<div>
<strong></strong>
<span></span>
</div>
<Button type="button" size="xs" variant="outline" onClick={() => props.onChange([
...props.value,
createRateLimitRuleForm(nextAvailableMetric(props.value)),
])}>
<Plus size={13} />
</Button>
</header>
<div className="userGroupPolicyTable userGroupRateLimitTable" role="table" aria-label="用户组限流策略">
<div className="userGroupPolicyRow userGroupPolicyTableHead" role="row">
<span role="columnheader"></span>
<span role="columnheader"></span>
<span role="columnheader"></span>
<span role="columnheader"> TTL</span>
<span role="columnheader"></span>
<span role="columnheader"></span>
</div>
{props.value.map((rule) => (
<div className="userGroupPolicyRow" role="row" key={rule.id}>
<Select aria-label="限流指标" size="sm" value={rule.metric} onChange={(event) => update(rule.id, { metric: event.target.value as RateLimitMetric })}>
{rateLimitMetrics.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
<Input aria-label={`${metricLabel(rule.metric)}上限`} size="sm" inputMode="decimal" value={rule.limit} placeholder="不限" onChange={(event) => update(rule.id, { limit: event.target.value })} />
<Input aria-label={`${metricLabel(rule.metric)}窗口秒数`} size="sm" inputMode="numeric" value={rule.windowSeconds} placeholder={rule.metric === 'concurrent' ? '-' : '60'} onChange={(event) => update(rule.id, { windowSeconds: event.target.value })} />
<Input aria-label={`${metricLabel(rule.metric)}租约 TTL`} size="sm" inputMode="numeric" value={rule.leaseTtlSeconds} placeholder={rule.metric === 'concurrent' ? '120' : '-'} onChange={(event) => update(rule.id, { leaseTtlSeconds: event.target.value })} />
<Select aria-label={`${metricLabel(rule.metric)}消费方式`} size="sm" value={rule.consume} onChange={(event) => update(rule.id, { consume: event.target.value })}>
<option value=""></option>
<option value="fixed_window"></option>
<option value="reserve_then_reconcile"></option>
{rule.consume && !['fixed_window', 'reserve_then_reconcile'].includes(rule.consume) && <option value={rule.consume}>{rule.consume}</option>}
</Select>
<Button type="button" aria-label={`删除${metricLabel(rule.metric)}规则`} size="xs" variant="ghost" onClick={() => props.onChange(props.value.filter((item) => item.id !== rule.id))}>
<Trash2 size={13} />
</Button>
</div>
))}
{!props.value.length && <div className="userGroupPolicyEmpty"></div>}
</div>
</section>
);
}
export function UserGroupQuotaPolicyEditor(props: {
value: UserGroupQuotaEntryForm[];
onChange: (value: UserGroupQuotaEntryForm[]) => void;
}) {
function update(id: string, patch: Partial<UserGroupQuotaEntryForm>) {
props.onChange(props.value.map((item) => item.id === id ? { ...item, ...patch } : item));
}
return (
<section className="userGroupPolicyEditor spanTwo">
<header className="userGroupPolicyHeader">
<div>
<strong></strong>
<span> JSON</span>
</div>
<Button type="button" size="xs" variant="outline" onClick={() => props.onChange([
...props.value,
createQuotaEntryForm(),
])}>
<Plus size={13} />
</Button>
</header>
<div className="userGroupPolicyTable userGroupQuotaTable" role="table" aria-label="用户组额度策略">
<div className="userGroupPolicyRow userGroupPolicyTableHead" role="row">
<span role="columnheader"></span>
<span role="columnheader"></span>
<span role="columnheader"></span>
<span role="columnheader"></span>
</div>
{props.value.map((entry) => (
<div className="userGroupPolicyRow" role="row" key={entry.id}>
<Input aria-label="额度字段" size="sm" value={entry.key} placeholder="例如 monthlyResource" onChange={(event) => update(entry.id, { key: event.target.value })} />
<Select
aria-label={`${entry.key || '额度'}值类型`}
size="sm"
value={entry.valueType}
onChange={(event) => {
const valueType = event.target.value as UserGroupQuotaEntryForm['valueType'];
update(entry.id, { valueType, value: quotaValueForType(valueType, entry.value) });
}}
>
<option value="number"></option>
<option value="string"></option>
<option value="boolean"></option>
<option value="json">JSON</option>
</Select>
{entry.valueType === 'boolean' ? (
<Select aria-label={`${entry.key || '额度'}`} size="sm" value={entry.value} onChange={(event) => update(entry.id, { value: event.target.value })}>
<option value="true">true</option>
<option value="false">false</option>
</Select>
) : (
<Input
aria-label={`${entry.key || '额度'}`}
size="sm"
inputMode={entry.valueType === 'number' ? 'decimal' : undefined}
value={entry.value}
placeholder={entry.valueType === 'json' ? '{"key":"value"}' : '请输入值'}
onChange={(event) => update(entry.id, { value: event.target.value })}
/>
)}
<Button type="button" aria-label={`删除额度字段 ${entry.key || '未命名'}`} size="xs" variant="ghost" onClick={() => props.onChange(props.value.filter((item) => item.id !== entry.id))}>
<Trash2 size={13} />
</Button>
</div>
))}
{!props.value.length && <div className="userGroupPolicyEmpty"></div>}
</div>
</section>
);
}
export function rateLimitPolicyToForm(policy?: RateLimitPolicy) {
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
const extra = { ...(policy ?? {}) };
delete extra.rules;
return {
extra,
rules: rules.map((rule) => {
const raw = rule as unknown as Record<string, unknown>;
const ruleExtra = { ...raw };
delete ruleExtra.metric;
delete ruleExtra.limit;
delete ruleExtra.windowSeconds;
delete ruleExtra.leaseTtlSeconds;
delete ruleExtra.consume;
return {
id: nextPolicyEditorRowId('rate'),
metric: normalizeMetric(rule.metric),
limit: numberText(rule.limit),
windowSeconds: numberText(rule.windowSeconds),
leaseTtlSeconds: numberText(rule.leaseTtlSeconds),
consume: typeof rule.consume === 'string' ? rule.consume : '',
extra: ruleExtra,
};
}),
};
}
export function rateLimitPolicyFromForm(
rules: UserGroupRateLimitRuleForm[],
extra: Record<string, unknown>,
): RateLimitPolicy | undefined {
const metrics = new Set<string>();
const normalizedRules = rules.flatMap((rule) => {
if (!rule.limit.trim()) return [];
if (metrics.has(rule.metric)) throw new Error(`限流指标 ${metricLabel(rule.metric)} 不能重复`);
metrics.add(rule.metric);
const limit = positiveNumber(rule.limit, `${metricLabel(rule.metric)}上限`);
const windowSeconds = optionalPositiveInteger(rule.windowSeconds, `${metricLabel(rule.metric)}窗口`);
const leaseTtlSeconds = optionalPositiveInteger(rule.leaseTtlSeconds, `${metricLabel(rule.metric)}租约 TTL`);
return [{
...rule.extra,
metric: rule.metric,
limit,
...(windowSeconds ? { windowSeconds } : {}),
...(leaseTtlSeconds ? { leaseTtlSeconds } : {}),
...(rule.consume ? { consume: rule.consume } : {}),
}];
});
const policy = {
...extra,
...(normalizedRules.length ? { rules: normalizedRules } : {}),
};
return Object.keys(policy).length ? policy : undefined;
}
export function quotaPolicyToForm(policy?: Record<string, unknown>) {
return Object.entries(policy ?? {}).map(([key, value]) => {
const serialized = serializeQuotaValue(value);
return {
id: nextPolicyEditorRowId('quota'),
key,
valueType: serialized.valueType,
value: serialized.value,
};
});
}
export function quotaPolicyFromForm(entries: UserGroupQuotaEntryForm[]) {
const policy: Record<string, unknown> = {};
entries.forEach((entry) => {
const key = entry.key.trim();
if (!key && !entry.value.trim()) return;
if (!key) throw new Error('额度字段不能为空');
if (Object.prototype.hasOwnProperty.call(policy, key)) throw new Error(`额度字段 ${key} 不能重复`);
policy[key] = parseQuotaValue(entry);
});
return Object.keys(policy).length ? policy : undefined;
}
export function rateLimitPolicySummary(policy?: RateLimitPolicy) {
const rules = Array.isArray(policy?.rules) ? policy.rules : [];
const parts = rules
.filter((rule) => Number.isFinite(Number(rule.limit)) && Number(rule.limit) > 0)
.map((rule) => `${metricShortLabel(rule.metric)} ${compactNumber(Number(rule.limit))}`);
if (parts.length) return parts;
return Object.entries(policy ?? {})
.filter(([key]) => key !== 'rules')
.slice(0, 3)
.map(([key, value]) => `${key} ${compactValue(value)}`);
}
export function quotaPolicySummary(policy?: Record<string, unknown>) {
return Object.entries(policy ?? {}).slice(0, 3).map(([key, value]) => `${key} ${compactValue(value)}`);
}
function createRateLimitRuleForm(metric: RateLimitMetric): UserGroupRateLimitRuleForm {
return {
id: nextPolicyEditorRowId('rate'),
metric,
limit: '',
windowSeconds: metric === 'concurrent' ? '' : '60',
leaseTtlSeconds: metric === 'concurrent' ? '120' : '',
consume: '',
extra: {},
};
}
function createQuotaEntryForm(): UserGroupQuotaEntryForm {
return {
id: nextPolicyEditorRowId('quota'),
key: '',
valueType: 'number',
value: '',
};
}
function nextAvailableMetric(rules: UserGroupRateLimitRuleForm[]) {
return rateLimitMetrics.find((item) => !rules.some((rule) => rule.metric === item.value))?.value ?? 'rpm';
}
function nextPolicyEditorRowId(prefix: string) {
policyEditorRowSequence += 1;
return `${prefix}-${policyEditorRowSequence}`;
}
function normalizeMetric(value: string): RateLimitMetric {
return rateLimitMetrics.some((item) => item.value === value) ? value as RateLimitMetric : 'rpm';
}
function metricLabel(metric: string) {
return rateLimitMetrics.find((item) => item.value === metric)?.label ?? metric;
}
function metricShortLabel(metric: string) {
return ({
rpm: 'RPM',
rps: 'RPS',
tpm_total: 'TPM',
tpm_input: '输入 TPM',
tpm_output: '输出 TPM',
concurrent: '并发',
queue_size: '排队',
} as Record<string, string>)[metric] ?? metric;
}
function numberText(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : '';
}
function positiveNumber(value: string, label: string) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${label}必须是大于 0 的数字`);
return parsed;
}
function optionalPositiveInteger(value: string, label: string) {
if (!value.trim()) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label}必须是大于 0 的整数`);
return parsed;
}
function serializeQuotaValue(value: unknown): Pick<UserGroupQuotaEntryForm, 'valueType' | 'value'> {
if (typeof value === 'number') return { valueType: 'number', value: String(value) };
if (typeof value === 'boolean') return { valueType: 'boolean', value: String(value) };
if (typeof value === 'string') return { valueType: 'string', value };
return { valueType: 'json', value: JSON.stringify(value) ?? 'null' };
}
function parseQuotaValue(entry: UserGroupQuotaEntryForm) {
if (entry.valueType === 'string') return entry.value;
if (entry.valueType === 'boolean') return entry.value === 'true';
if (entry.valueType === 'number') {
const value = Number(entry.value);
if (!Number.isFinite(value) || value < 0) throw new Error(`额度字段 ${entry.key} 必须是大于或等于 0 的数字`);
return value;
}
try {
return JSON.parse(entry.value) as unknown;
} catch {
throw new Error(`额度字段 ${entry.key} 必须是有效 JSON`);
}
}
function quotaValueForType(valueType: UserGroupQuotaEntryForm['valueType'], current: string) {
if (valueType === 'boolean') return current === 'false' ? 'false' : 'true';
if (valueType === 'number') return Number.isFinite(Number(current)) ? current : '';
if (valueType === 'json') {
try {
JSON.parse(current);
return current;
} catch {
return '{}';
}
}
return current;
}
function compactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
}
function compactValue(value: unknown) {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
const text = JSON.stringify(value) ?? String(value);
return text.length > 18 ? `${text.slice(0, 17)}` : text;
}