功能:完善渠道模型批量操作

支持模型管理批量删除,并在拉取模型时提供全选、取消全选和选择数量提示。

同时保持首屏品牌中立,补充后端原子删除与管理员界面回归测试。
This commit is contained in:
Ray
2026-09-07 10:18:06 +08:00
parent df94c3bdf1
commit 2ce443731d
9 changed files with 361 additions and 39 deletions

View File

@@ -19,13 +19,13 @@
position: fixed;
left: 50%;
top: 50%;
width: 42px;
height: 42px;
margin: -21px 0 0 -21px;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
background: rgba(255, 255, 255, 0.84);
mask: url('/logo.svg') center / contain no-repeat;
-webkit-mask: url('/logo.svg') center / contain no-repeat;
animation: appearance-bootstrap-logo 1.8s ease-in-out infinite;
border-radius: 999px;
box-shadow: 0 0 0 7px rgba(255, 255, 255, 0.08);
animation: appearance-bootstrap-pulse 1.8s ease-in-out infinite;
}
#root:empty::after {
content: "";
@@ -40,7 +40,7 @@
border-radius: 999px;
animation: appearance-bootstrap-orbit 2.8s linear infinite;
}
@keyframes appearance-bootstrap-logo { 0%, 100% { opacity: .62; transform: scale(.94); } 50% { opacity: 1; transform: scale(1.04); } }
@keyframes appearance-bootstrap-pulse { 0%, 100% { opacity: .62; transform: scale(.82); } 50% { opacity: 1; transform: scale(1.08); } }
@keyframes appearance-bootstrap-orbit { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
#root:empty::before,

View File

@@ -7,15 +7,25 @@ import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIcon } from "@/components/model-picker";
import { modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog";
import { deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet";
import {
deleteAdminChannelModel,
deleteAdminChannelModels,
fetchAdminChannelModels,
importAdminChannelModels,
listAdminChannelModels,
type ChannelModel,
type ChannelModelPriceTier,
} from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { ChannelModelEditor } from "./channel-model-editor";
import { AdminPageFrame } from "./admin-shell";
import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const [items, setItems] = useState<ChannelModel[]>([]);
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
const [deletingSelected, setDeletingSelected] = useState(false);
const [editing, setEditing] = useState<ChannelModel | null>(null);
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
@@ -48,7 +58,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
if (!channel) return;
setLoading(true);
try {
setItems((await listAdminChannelModels(channel.id)).models);
const models = (await listAdminChannelModels(channel.id)).models;
const availableIDs = new Set(models.map((item) => item.id));
setItems(models);
setSelectedModelIds((current) => current.filter((id) => availableIDs.has(id)));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取渠道模型失败");
} finally {
@@ -61,6 +74,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
void loadProtocols();
setEditing(null);
setEditorOpen(false);
setSelectedModelIds([]);
resetFetchPreview();
setKeyword("");
setCapability("all");
@@ -139,6 +153,32 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
};
const confirmBatchRemove = () => {
if (!selectedModelIds.length) return;
const count = selectedModelIds.length;
modal.confirm({
title: `删除已选的 ${count} 个模型?`,
content: "删除后模型不再显示,且不能在页面恢复。只要其中任一模型仍被前台供应线路或进行中任务使用,本次就不会删除任何模型。",
okText: "批量删除",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
setDeletingSelected(true);
try {
const result = await deleteAdminChannelModels(channel.id, selectedModelIds);
setSelectedModelIds([]);
setPage(1);
await reload();
await onChanged();
message.success(`已删除 ${result.deleted} 个模型`);
} catch (error) {
message.error(error instanceof Error ? error.message : "批量删除模型失败");
} finally {
setDeletingSelected(false);
}
},
});
};
const columns: ColumnsType<ChannelModel> = [
{
title: "模型",
@@ -178,11 +218,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
width: 180,
render: (_, item) => (
<Space>
<Button size="small" onClick={() => startEdit(item)}>
<Button size="small" disabled={deletingSelected} onClick={() => startEdit(item)}>
编辑
</Button>
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Button size="small" danger title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
<Button size="small" danger disabled={deletingSelected} title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
</Popconfirm>
</Space>
),
@@ -201,6 +241,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const existingFetchModelKeys = new Set(items.map((item) => normalizeFetchModelKey(item.modelKey)));
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 alreadyExists = existingFetchModelKeys.has(normalizeFetchModelKey(name));
return {
@@ -315,11 +356,27 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
setStatus("all");
setPage(1);
}}
batchActions={
<AdminBatchBar count={selectedModelIds.length} onClear={() => setSelectedModelIds([])}>
<Button danger size="small" icon={<Trash2 className="size-3.5" />} loading={deletingSelected} onClick={confirmBatchRemove}>
批量删除
</Button>
</AdminBatchBar>
}
table={{
className: "app-data-table",
rowKey: "id",
size: "small",
loading,
rowSelection: {
selectedRowKeys: selectedModelIds,
preserveSelectedRowKeys: true,
onChange: (keys) => {
const next = keys.map(String);
if (next.length > 100) message.warning("单次最多选择 100 个模型");
setSelectedModelIds(next.slice(0, 100));
},
},
columns,
dataSource: pagedItems,
pagination: false,
@@ -357,9 +414,28 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
]}
>
<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>
<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} 个模型
</span>
<Space size={4}>
<Button size="small" disabled={importing || allFetchModelsSelected} onClick={() => setSelectedFetchModels(fetchPreviewModels)}>
全选
</Button>
<Button size="small" disabled={importing || selectedFetchModels.length === 0} onClick={() => setSelectedFetchModels([])}>
取消全选
</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} onChange={(values) => setSelectedFetchModels(values as string[])} />
<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[])}
/>
</div>
<div className="text-xs text-foreground/50">
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"}

View File

@@ -295,6 +295,10 @@ export function deleteAdminChannelModel(channelId: string, id: string) {
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`));
}
export function deleteAdminChannelModels(channelId: string, modelIds: string[]) {
return request<{ deleted: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/batch-delete`, { modelIds }));
}
export type AdminFinanceListParams = { keyword?: string; status?: string; validity?: string; page?: number; limit?: number };
export function listAdminRedeemBatches(params: AdminFinanceListParams = {}) {

View File

@@ -87,6 +87,13 @@ test("channel model fetch requires explicit selection before import", async () =
expect(component).toContain("默认已全选");
expect(component).toContain("setFetchPreviewOpen(true)");
expect(component).toContain("setSelectedFetchModels(result.models)");
expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型");
expect(component).toContain("disabled={importing || allFetchModelsSelected}");
expect(component).toContain("onClick={() => setSelectedFetchModels(fetchPreviewModels)}");
expect(component).toContain("disabled={importing || selectedFetchModels.length === 0}");
expect(component).toContain("onClick={() => setSelectedFetchModels([])}");
expect(component).toContain("取消全选");
expect(component).toContain("disabled={importing}");
expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)");
expect(component).toContain("disabled={!selectedFetchModels.length}");
expect(component).not.toContain("disabled: alreadyExists");
@@ -95,6 +102,22 @@ test("channel model fetch requires explicit selection before import", async () =
expect(adminCssSource).toContain("border-color: var(--control-check-fg) !important");
});
test("channel model manager supports bounded atomic batch deletion", async () => {
const [componentSource, apiSource] = await Promise.all([Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text(), Bun.file(new URL("../src/services/api/wallet.ts", import.meta.url)).text()]);
const component = compactSource(componentSource);
expect(apiSource).toContain("api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/batch-delete`, { modelIds })");
expect(component).toContain("<AdminBatchBar count={selectedModelIds.length}");
expect(component).toContain("rowSelection:");
expect(component).toContain("selectedRowKeys: selectedModelIds");
expect(component).toContain("preserveSelectedRowKeys: true");
expect(component).toContain("setSelectedModelIds(next.slice(0, 100))");
expect(component).toContain("deleteAdminChannelModels(channel.id, selectedModelIds)");
expect(component).toContain("okButtonProps: { danger: true }");
expect(component).toContain("本次就不会删除任何模型");
expect(component).toContain("批量删除");
});
test("analytics keeps fixed range presets distinct and uses enabled channel models for pricing", async () => {
const source = compactSource(await Bun.file(new URL("../src/pages/admin/components/analytics-panel.tsx", import.meta.url)).text());