From e70fe1b9c93d6af3b807a87ed15bb621af78095c Mon Sep 17 00:00:00 2001 From: xupengchong-del Date: Mon, 12 Oct 2026 11:05:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=B3=BB=E7=BB=9F=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=20-=20=E3=80=8C=E6=8B=89=E5=8F=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E3=80=8D=E5=BC=B9=E7=AA=97=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=20(#743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部分上游端点会返回几百个模型,弹窗里铺满复选框后只能靠肉眼滚动查找, 没法按厂商或系列收窄。 - 弹窗顶部新增搜索框(大小写不敏感的子串匹配、自动聚焦、可一键清空); - 搜索时「全选 / 取消选中」只作用于当前匹配集合,按钮文案带出匹配数量; 关键字之外的勾选状态保留。无关键字时与改动前行为完全一致; - 无命中时列表区显示空态;汇总行补充匹配数与其中已勾选数。 筛选与批量勾选规则抽到 channel-model-import-selection.ts 并配套单测。 --- docs/content/docs/progress/pending-test.mdx | 9 +++ .../channel-model-import-selection.ts | 20 +++++++ .../components/channel-model-manager.tsx | 58 ++++++++++++++----- .../channel-model-import-selection.test.ts | 52 +++++++++++++++++ 4 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 web/src/pages/admin/components/channel-model-import-selection.ts create mode 100644 web/test/channel-model-import-selection.test.ts diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 7ef2ea7a2..6e38a1e18 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 仍按静态文件提供。 diff --git a/web/src/pages/admin/components/channel-model-import-selection.ts b/web/src/pages/admin/components/channel-model-import-selection.ts new file mode 100644 index 000000000..062270a0f --- /dev/null +++ b/web/src/pages/admin/components/channel-model-import-selection.ts @@ -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)); +} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index e28af1f15..606a51fd0 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -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 }) { @@ -29,6 +30,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan const [fetchPreviewOpen, setFetchPreviewOpen] = useState(false); const [fetchPreviewModels, setFetchPreviewModels] = useState([]); const [selectedFetchModels, setSelectedFetchModels] = useState([]); + 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 >

上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。

+ } + value={fetchKeyword} + placeholder="搜索模型标识,例如 minimax、sd2.5" + aria-label="搜索上游模型" + disabled={importing} + onChange={(event) => setFetchKeyword(event.target.value)} + />
已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型 + {fetchKeywordQuery ? `,当前匹配 ${visibleFetchModels.length} 个(其中 ${selectedVisibleFetchCount} 个已勾选)` : ""} - -
- setSelectedFetchModels(values as string[])} - /> + {visibleFetchModels.length ? ( + setSelectedFetchModels(values as string[])} + /> + ) : ( +

+ 没有匹配「{fetchKeyword.trim()}」的模型 +

+ )}
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"} diff --git a/web/test/channel-model-import-selection.test.ts b/web/test/channel-model-import-selection.test.ts new file mode 100644 index 000000000..b9c6fe52b --- /dev/null +++ b/web/test/channel-model-import-selection.test.ts @@ -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([]); +});