feat(admin): 系统模型 - 「拉取模型」弹窗支持搜索 (#743)

部分上游端点会返回几百个模型,弹窗里铺满复选框后只能靠肉眼滚动查找,
没法按厂商或系列收窄。

- 弹窗顶部新增搜索框(大小写不敏感的子串匹配、自动聚焦、可一键清空);
- 搜索时「全选 / 取消选中」只作用于当前匹配集合,按钮文案带出匹配数量;
  关键字之外的勾选状态保留。无关键字时与改动前行为完全一致;
- 无命中时列表区显示空态;汇总行补充匹配数与其中已勾选数。

筛选与批量勾选规则抽到 channel-model-import-selection.ts 并配套单测。
This commit is contained in:
xupengchong-del
2026-10-12 11:05:49 +08:00
committed by GitHub
parent 16b70709b4
commit e70fe1b9c9
4 changed files with 126 additions and 13 deletions

View File

@@ -5,6 +5,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 系统模型「拉取模型」弹窗搜索
- 已实现:后台「模型与资源 → 系统模型」点「拉取模型」后的选择弹窗新增搜索框(按模型标识做大小写不敏感的子串匹配,自动聚焦、可一键清空),因为部分上游端点会返回几百个模型。
- 已实现:搜索时「全选 / 取消选中」只作用于**当前匹配**的集合,按钮文案同步显示匹配数量(如「全选匹配的 12 个」);关键字之外的勾选状态保留。无关键字时行为与改动前完全一致。
- 已实现:搜索无命中时列表区显示「没有匹配「关键字」的模型」空态;汇总行额外显示匹配数与其中已勾选数。
- 实现位置:筛选与批量勾选规则抽到 `web/src/pages/admin/components/channel-model-import-selection.ts`,弹窗接线在 `channel-model-manager.tsx`,配套 `web/test/channel-model-import-selection.test.ts`。
- [x] 验证:`bun test test/channel-model-import-selection.test.ts` 9 项通过(空关键字返回全部、大小写不敏感、首尾空白、无命中返回空、全选并入去重、关键字外勾选保留、无关键字时与旧行为一致);`tsc --noEmit`、ESLint、`bun run build` 均通过。
- [ ] 用户验收:需要管理员登录且上游能返回大量模型才能打开该弹窗,因此**未做真实界面验收**。请在真实后台点「拉取模型」确认:搜索框位置与自动聚焦、输入关键字后列表实时收窄、全选/取消选中只影响匹配项、清空后恢复全部、以及几百个模型时的滚动与性能。
## 灵感页路由与导航
- 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。

View File

@@ -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));
}

View File

@@ -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} 个新模型` : "当前勾选的模型均已存在"}

View 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([]);
});