功能:完善渠道模型批量操作
支持模型管理批量删除,并在拉取模型时提供全选、取消全选和选择数量提示。 同时保持首屏品牌中立,补充后端原子删除与管理员界面回归测试。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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} 个新模型` : "当前勾选的模型均已存在"}
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user