feat(admin): 系统模型 - 「拉取模型」弹窗支持搜索 (#743)
部分上游端点会返回几百个模型,弹窗里铺满复选框后只能靠肉眼滚动查找, 没法按厂商或系列收窄。 - 弹窗顶部新增搜索框(大小写不敏感的子串匹配、自动聚焦、可一键清空); - 搜索时「全选 / 取消选中」只作用于当前匹配集合,按钮文案带出匹配数量; 关键字之外的勾选状态保留。无关键字时与改动前行为完全一致; - 无命中时列表区显示空态;汇总行补充匹配数与其中已勾选数。 筛选与批量勾选规则抽到 channel-model-import-selection.ts 并配套单测。
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
// 「拉取模型」预览弹窗的筛选与批量勾选规则。
|
||||
// 上游端点可能返回几百个模型,弹窗里先按关键字收窄,再对收窄后的集合做批量勾选。
|
||||
|
||||
/** 关键字为空(或只有空白)时返回全部;否则按大小写不敏感的子串匹配模型标识。 */
|
||||
export function filterImportModels(models: string[], keyword: string): string[] {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
if (!query) return models;
|
||||
return models.filter((name) => name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
/** 全选:并入当前可见集合,保留关键字外已经勾选的模型。 */
|
||||
export function selectVisibleModels(current: string[], visible: string[]): string[] {
|
||||
return Array.from(new Set([...current, ...visible]));
|
||||
}
|
||||
|
||||
/** 取消选中:只移除当前可见集合里的勾选,关键字外的勾选保持不变。 */
|
||||
export function deselectVisibleModels(current: string[], visible: string[]): string[] {
|
||||
const hidden = new Set(visible);
|
||||
return current.filter((name) => !hidden.has(name));
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge, Admin
|
||||
import { ChannelOrderDialog } from "./channel-order-dialog";
|
||||
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
|
||||
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
|
||||
import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "./channel-model-import-selection";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
|
||||
export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise<void> }) {
|
||||
@@ -29,6 +30,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
|
||||
const [fetchPreviewOpen, setFetchPreviewOpen] = useState(false);
|
||||
const [fetchPreviewModels, setFetchPreviewModels] = useState<string[]>([]);
|
||||
const [selectedFetchModels, setSelectedFetchModels] = useState<string[]>([]);
|
||||
const [fetchKeyword, setFetchKeyword] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -108,6 +110,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
|
||||
setFetchPreviewOpen(false);
|
||||
setFetchPreviewModels([]);
|
||||
setSelectedFetchModels([]);
|
||||
setFetchKeyword("");
|
||||
};
|
||||
|
||||
const importSelectedModels = async () => {
|
||||
@@ -245,8 +248,11 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
|
||||
const existingFetchModelKeys = new Set(items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean).map(normalizeFetchModelKey)));
|
||||
const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name)));
|
||||
const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length;
|
||||
const allFetchModelsSelected = fetchPreviewModels.length > 0 && fetchPreviewModels.every((name) => selectedFetchModels.includes(name));
|
||||
const fetchModelOptions = fetchPreviewModels.map((name) => {
|
||||
const fetchKeywordQuery = fetchKeyword.trim().toLowerCase();
|
||||
const visibleFetchModels = filterImportModels(fetchPreviewModels, fetchKeyword);
|
||||
const allVisibleFetchModelsSelected = visibleFetchModels.length > 0 && visibleFetchModels.every((name) => selectedFetchModels.includes(name));
|
||||
const selectedVisibleFetchCount = visibleFetchModels.filter((name) => selectedFetchModels.includes(name)).length;
|
||||
const fetchModelOptions = visibleFetchModels.map((name) => {
|
||||
const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name));
|
||||
return {
|
||||
label: alreadyExists ? (
|
||||
@@ -451,27 +457,53 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。</p>
|
||||
<Input
|
||||
allowClear
|
||||
autoFocus
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={fetchKeyword}
|
||||
placeholder="搜索模型标识,例如 minimax、sd2.5"
|
||||
aria-label="搜索上游模型"
|
||||
disabled={importing}
|
||||
onChange={(event) => setFetchKeyword(event.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/70 bg-muted/25 px-3 py-2">
|
||||
<span className="text-sm font-medium text-foreground/70" aria-live="polite">
|
||||
已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型
|
||||
{fetchKeywordQuery ? `,当前匹配 ${visibleFetchModels.length} 个(其中 ${selectedVisibleFetchCount} 个已勾选)` : ""}
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<Button size="small" disabled={importing || allFetchModelsSelected} onClick={() => setSelectedFetchModels(fetchPreviewModels)}>
|
||||
全选
|
||||
<Button
|
||||
size="small"
|
||||
disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}
|
||||
onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}
|
||||
>
|
||||
{fetchKeywordQuery ? `全选匹配的 ${visibleFetchModels.length} 个` : "全选"}
|
||||
</Button>
|
||||
<Button size="small" disabled={importing || selectedFetchModels.length === 0} onClick={() => setSelectedFetchModels([])}>
|
||||
取消全选
|
||||
<Button
|
||||
size="small"
|
||||
disabled={importing || selectedVisibleFetchCount === 0}
|
||||
onClick={() => setSelectedFetchModels((current) => deselectVisibleModels(current, visibleFetchModels))}
|
||||
>
|
||||
{fetchKeywordQuery ? "取消选中匹配的" : "取消全选"}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="max-h-[min(60vh,520px)] overflow-y-auto rounded-md border border-border/70 p-3">
|
||||
<Checkbox.Group
|
||||
className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
value={selectedFetchModels}
|
||||
options={fetchModelOptions}
|
||||
disabled={importing}
|
||||
onChange={(values) => setSelectedFetchModels(values as string[])}
|
||||
/>
|
||||
{visibleFetchModels.length ? (
|
||||
<Checkbox.Group
|
||||
className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
|
||||
value={selectedFetchModels}
|
||||
options={fetchModelOptions}
|
||||
disabled={importing}
|
||||
onChange={(values) => setSelectedFetchModels(values as string[])}
|
||||
/>
|
||||
) : (
|
||||
<p className="m-0 py-6 text-center text-sm text-foreground/55">
|
||||
没有匹配「{fetchKeyword.trim()}」的模型
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-foreground/50">
|
||||
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"}
|
||||
|
||||
52
web/test/channel-model-import-selection.test.ts
Normal file
52
web/test/channel-model-import-selection.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "../src/pages/admin/components/channel-model-import-selection";
|
||||
|
||||
const MODELS = [
|
||||
"MiniMax-H3",
|
||||
"004系列/MiniMax-H3(933/10-15秒768P)",
|
||||
"004系列/sd2.5(30--0-10卡脸4-30秒/720P)",
|
||||
"gpt-image-1",
|
||||
"doubao-seedream-4",
|
||||
];
|
||||
|
||||
test("关键字为空时返回全部模型", () => {
|
||||
expect(filterImportModels(MODELS, "")).toEqual(MODELS);
|
||||
expect(filterImportModels(MODELS, " ")).toEqual(MODELS);
|
||||
});
|
||||
|
||||
test("按子串匹配,且大小写不敏感", () => {
|
||||
expect(filterImportModels(MODELS, "minimax")).toEqual([MODELS[0], MODELS[1]]);
|
||||
expect(filterImportModels(MODELS, "MINIMAX")).toEqual([MODELS[0], MODELS[1]]);
|
||||
});
|
||||
|
||||
test("关键字首尾空白不影响匹配", () => {
|
||||
expect(filterImportModels(MODELS, " sd2.5 ")).toEqual([MODELS[2]]);
|
||||
});
|
||||
|
||||
test("没有命中时返回空数组(弹窗据此显示空态)", () => {
|
||||
expect(filterImportModels(MODELS, "不存在的模型")).toEqual([]);
|
||||
});
|
||||
|
||||
test("全选只并入当前可见集合,关键字外的勾选保留", () => {
|
||||
const visible = filterImportModels(MODELS, "minimax");
|
||||
expect(selectVisibleModels(["gpt-image-1"], visible)).toEqual(["gpt-image-1", MODELS[0], MODELS[1]]);
|
||||
});
|
||||
|
||||
test("全选不会产生重复项", () => {
|
||||
const visible = filterImportModels(MODELS, "minimax");
|
||||
expect(selectVisibleModels([MODELS[0]], visible)).toEqual([MODELS[0], MODELS[1]]);
|
||||
});
|
||||
|
||||
test("无关键字时全选等于选中全部,与旧行为一致", () => {
|
||||
expect(selectVisibleModels([], filterImportModels(MODELS, ""))).toEqual(MODELS);
|
||||
});
|
||||
|
||||
test("取消选中只移除可见的,隐藏的勾选不动", () => {
|
||||
const visible = filterImportModels(MODELS, "minimax");
|
||||
expect(deselectVisibleModels([MODELS[0], MODELS[1], "gpt-image-1"], visible)).toEqual(["gpt-image-1"]);
|
||||
});
|
||||
|
||||
test("无关键字时取消选中等于清空,与旧行为一致", () => {
|
||||
expect(deselectVisibleModels(MODELS, filterImportModels(MODELS, ""))).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user