feat(admin): 系统模型 - 「拉取模型」弹窗支持搜索 (#743)
部分上游端点会返回几百个模型,弹窗里铺满复选框后只能靠肉眼滚动查找, 没法按厂商或系列收窄。 - 弹窗顶部新增搜索框(大小写不敏感的子串匹配、自动聚焦、可一键清空); - 搜索时「全选 / 取消选中」只作用于当前匹配集合,按钮文案带出匹配数量; 关键字之外的勾选状态保留。无关键字时与改动前行为完全一致; - 无命中时列表区显示空态;汇总行补充匹配数与其中已勾选数。 筛选与批量勾选规则抽到 channel-model-import-selection.ts 并配套单测。
This commit is contained in:
@@ -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 仍按静态文件提供。
|
- 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。
|
||||||
|
|||||||
@@ -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 { ChannelOrderDialog } from "./channel-order-dialog";
|
||||||
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
|
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
|
||||||
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
|
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
|
||||||
|
import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "./channel-model-import-selection";
|
||||||
import { Select } from "@/components/ui/base/select";
|
import { Select } from "@/components/ui/base/select";
|
||||||
|
|
||||||
export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise<void> }) {
|
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 [fetchPreviewOpen, setFetchPreviewOpen] = useState(false);
|
||||||
const [fetchPreviewModels, setFetchPreviewModels] = useState<string[]>([]);
|
const [fetchPreviewModels, setFetchPreviewModels] = useState<string[]>([]);
|
||||||
const [selectedFetchModels, setSelectedFetchModels] = useState<string[]>([]);
|
const [selectedFetchModels, setSelectedFetchModels] = useState<string[]>([]);
|
||||||
|
const [fetchKeyword, setFetchKeyword] = useState("");
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
@@ -108,6 +110,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
|
|||||||
setFetchPreviewOpen(false);
|
setFetchPreviewOpen(false);
|
||||||
setFetchPreviewModels([]);
|
setFetchPreviewModels([]);
|
||||||
setSelectedFetchModels([]);
|
setSelectedFetchModels([]);
|
||||||
|
setFetchKeyword("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const importSelectedModels = async () => {
|
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 existingFetchModelKeys = new Set(items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean).map(normalizeFetchModelKey)));
|
||||||
const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name)));
|
const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name)));
|
||||||
const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length;
|
const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length;
|
||||||
const allFetchModelsSelected = fetchPreviewModels.length > 0 && fetchPreviewModels.every((name) => selectedFetchModels.includes(name));
|
const fetchKeywordQuery = fetchKeyword.trim().toLowerCase();
|
||||||
const fetchModelOptions = fetchPreviewModels.map((name) => {
|
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));
|
const alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name));
|
||||||
return {
|
return {
|
||||||
label: alreadyExists ? (
|
label: alreadyExists ? (
|
||||||
@@ -451,27 +457,53 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
|
|||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="m-0 text-sm text-foreground/65">上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,可批量全选或取消全选;已存在的模型不会重复导入。</p>
|
<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">
|
<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">
|
<span className="text-sm font-medium text-foreground/70" aria-live="polite">
|
||||||
已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型
|
已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型
|
||||||
|
{fetchKeywordQuery ? `,当前匹配 ${visibleFetchModels.length} 个(其中 ${selectedVisibleFetchCount} 个已勾选)` : ""}
|
||||||
</span>
|
</span>
|
||||||
<Space size={4}>
|
<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>
|
||||||
<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>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[min(60vh,520px)] overflow-y-auto rounded-md border border-border/70 p-3">
|
<div className="max-h-[min(60vh,520px)] overflow-y-auto rounded-md border border-border/70 p-3">
|
||||||
<Checkbox.Group
|
{visibleFetchModels.length ? (
|
||||||
className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
|
<Checkbox.Group
|
||||||
value={selectedFetchModels}
|
className="channel-model-import-picker grid w-full grid-cols-1 gap-2 sm:grid-cols-2"
|
||||||
options={fetchModelOptions}
|
value={selectedFetchModels}
|
||||||
disabled={importing}
|
options={fetchModelOptions}
|
||||||
onChange={(values) => setSelectedFetchModels(values as string[])}
|
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>
|
||||||
<div className="text-xs text-foreground/50">
|
<div className="text-xs text-foreground/50">
|
||||||
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"}
|
{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