feat(admin): 优化后台运维与任务管理
完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。 新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。 验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
This commit is contained in:
@@ -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']) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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">按平台模型查看实时 RPM、TPM、并发、排队、冷却和综合满载率。</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;
|
||||
}
|
||||
Reference in New Issue
Block a user