feat(*): 模型编辑与画布 - 统一编辑弹窗并稳定网格和后台切页

This commit is contained in:
ddCat
2026-09-06 07:19:28 +08:00
parent bf0c4454fa
commit 83ce57e65f
23 changed files with 1749 additions and 1678 deletions

View File

@@ -34,10 +34,7 @@ test("announcement editor preserves image and pinned fields through edit and sav
});
test("plugin upload owns native drops and price availability text remains readable", async () => {
const [pluginSource, adminCss] = await Promise.all([
Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(),
]);
const [pluginSource, adminCss] = await Promise.all([Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text(), Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text()]);
const toggleCss = sourceSection(adminCss, ".admin-price-tier-toggle span {", ".admin-model-editor-add-tier.ant-btn {");
expect(pluginSource).toContain("event.preventDefault()");
@@ -46,12 +43,36 @@ test("plugin upload owns native drops and price availability text remains readab
expect(pluginSource).toContain("点击选择插件文件,也可拖拽到此处");
expect(pluginSource).toContain("释放文件以上传插件");
expect(pluginSource).toContain("isDraggingPlugin");
expect(compactSource(adminCss)).toContain(".admin-price-tier-controls { display: grid !important; grid-template-columns: minmax(0, 1fr);");
expect(compactSource(adminCss)).toContain(".admin-price-tier-controls { margin-left: auto;");
expect(toggleCss).toContain("overflow-wrap: anywhere;");
expect(toggleCss).toContain("white-space: normal;");
expect(toggleCss).not.toContain("text-overflow: ellipsis;");
});
test("model reference limits use compact rows only inside the admin editor", async () => {
const css = await Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text();
const numberField = sourceSection(css, ".admin-model-editor-references .admin-capability-number-field {", ".admin-model-editor-references .admin-capability-boolean-field {");
expect(numberField).toContain("grid-template-columns: minmax(0, 1fr) 80px;");
expect(numberField).toContain("min-height: 32px;");
expect(numberField).toContain("align-items: center;");
const switches = sourceSection(css, ".admin-model-editor-references .admin-capability-boolean-field label {", "@media (min-width: 601px)");
expect(switches).toContain("display: flex;");
expect(compactSource(css)).toContain(".admin-model-editor-references .admin-capability-reference-grid { align-items: start;");
expect(compactSource(css)).toContain(".admin-model-editor-modal .admin-capability-reference-grid { grid-template-columns: minmax(0, 1fr);");
});
test("model editor presents protocols in a searchable inline radio browser instead of a dropdown", async () => {
const [source, css] = await Promise.all([
Bun.file(new URL("../src/pages/admin/components/channel-model-editor.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(),
]);
const protocolSection = sourceSection(source, '<Form.Item\n className="admin-model-protocol-field"', "{protocolError && (");
expect(protocolSection).toContain("<ModelProtocolBrowser");
expect(protocolSection).not.toContain("<Select");
expect(compactSource(css)).toContain(".admin-model-protocol-field { grid-column: 1 / -1;");
});
test("channel model fetch requires explicit selection before import", async () => {
const [componentSource, apiSource, adminCssSource] = await Promise.all([
Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text(),
@@ -60,8 +81,8 @@ test("channel model fetch requires explicit selection before import", async () =
]);
const component = compactSource(componentSource);
expect(apiSource).toContain('api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)');
expect(apiSource).toContain('api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })');
expect(apiSource).toContain("api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)");
expect(apiSource).toContain("api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })");
expect(component).toContain('title="选择要导入的模型"');
expect(component).toContain("默认已全选");
expect(component).toContain("setFetchPreviewOpen(true)");

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test";
import { createRef } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { InfiniteCanvas } from "../src/components/canvas/infinite-canvas";
import { canvasAppearanceForTheme } from "../src/lib/canvas/canvas-appearance";
import { applyCanvasLiveViewport, CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, CANVAS_VIEWPORT_PREVIEW_EVENT } from "../src/lib/canvas/canvas-live-viewport";
import type { CanvasBackgroundMode } from "../src/lib/canvas-theme";
import type { ViewportTransform } from "../src/types/canvas";
const viewports: ViewportTransform[] = [
{ x: 0, y: 0, k: 1 },
{ x: 389.5, y: -207.25, k: 0.05 },
{ x: -17.125, y: 20.875, k: 0.119 },
{ x: -17.125, y: 20.875, k: 0.12 },
{ x: 150.2, y: -40.1, k: 0.666 },
{ x: 150.2, y: -40.1, k: 0.667 },
{ x: -1800.5, y: 3100.75, k: 2 },
];
function renderGrid(mode: CanvasBackgroundMode, viewport: ViewportTransform, theme: "light" | "dark" = "dark") {
const markup = renderToStaticMarkup(
<InfiniteCanvas containerRef={createRef<HTMLDivElement>()} viewport={viewport} onViewportChange={() => {}} backgroundMode={mode} appearance={canvasAppearanceForTheme(theme)}>
<div data-test-node />
</InfiniteCanvas>,
);
return markup.match(/<div data-canvas-grid-layer[^>]*>/)?.[0] ?? null;
}
describe("canvas screen-space background", () => {
for (const mode of ["lines", "dots"] as const) {
test(`${mode} keeps fixed geometry across zoom, pan and committed renders`, () => {
for (const theme of ["light", "dark"] as const) {
const initial = renderGrid(mode, viewports[0], theme);
expect(initial).not.toBeNull();
expect(initial).toContain("background-size:48px 48px");
expect(initial).toContain("inset:0");
expect(initial).not.toContain("transform:");
expect(initial).not.toContain("var(--canvas-");
if (mode === "dots") expect(initial).toContain("0.8px, transparent 1px");
for (const viewport of viewports) expect(renderGrid(mode, viewport, theme)).toBe(initial);
}
});
}
test("blank mode renders no grid at any viewport", () => {
for (const viewport of viewports) expect(renderGrid("blank", viewport)).toBeNull();
});
test("live updates transform the world and emit events without mutating the background", () => {
const properties = new Map<string, string>([["--canvas-committed-scale", "0.5"]]);
const gridWrites: string[] = [];
const world = { style: { transform: "", transformOrigin: "", willChange: "" } };
const grid = { style: { setProperty: (name: string) => gridWrites.push(name) } };
const container = Object.assign(new EventTarget(), {
style: {
getPropertyValue: (name: string) => properties.get(name) ?? "",
setProperty: (name: string, value: string) => properties.set(name, value),
},
dataset: { canvasViewportInteracting: "true" },
querySelector: (selector: string) => selector === "[data-canvas-world-layer]" ? world : grid,
});
const graphics: ViewportTransform[] = [];
const previews: ViewportTransform[] = [];
let scrolls = 0;
container.addEventListener(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, (event) => graphics.push((event as CustomEvent<ViewportTransform>).detail));
container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, (event) => previews.push((event as CustomEvent<ViewportTransform>).detail));
container.addEventListener("scroll", () => scrolls++);
for (const viewport of viewports) {
applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewport, false);
expect(world.style.transform).toBe(`translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / 0.5})`);
expect(properties.get("--canvas-live-scale")).toBe(String(viewport.k));
expect(properties.get("--canvas-live-inverse-scale")).toBe(String(1 / viewport.k));
expect(gridWrites).toEqual([]);
}
expect(graphics).toEqual(viewports);
expect(previews).toEqual([]);
expect(scrolls).toBe(0);
expect(world.style.willChange).toBe("transform");
container.dataset.canvasViewportInteracting = "false";
applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewports[0]);
expect(world.style.willChange).toBe("");
expect(previews).toEqual([viewports[0]]);
expect(scrolls).toBe(1);
expect(gridWrites).toEqual([]);
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test";
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
import type { ModelProtocolDefinition } from "../src/lib/model-protocols";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form";
const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" });
const protocols = [definition("disabled-text", "text", false), definition("text", "text"), definition("image", "image"), definition("video", "video")];
describe("channel model editor drafts", () => {
test("new drafts select an enabled protocol and never share price state", () => {
const first = initialChannelModelValues(null, protocols);
first.priceTiers[0].unitPrice = 42;
const second = initialChannelModelValues(null, protocols);
expect(second.protocol).toBe("text");
expect(second.priceTiers[0].unitPrice).toBe(0);
expect(second.modelKey).toBe("");
});
test("missing catalogs do not invent a protocol", () => {
expect(initialChannelModelValues(null, []).protocol).toBeUndefined();
expect(() => validateChannelModelProtocol("text", "text", [])).toThrow("已启用");
});
test("rejects disabled, missing and capability-mismatched protocols", () => {
for (const protocol of [undefined, "missing", "disabled-text", "image"]) {
expect(() => validateChannelModelProtocol("text", protocol, protocols)).toThrow();
}
expect(() => validateChannelModelProtocol("text", "text", protocols)).not.toThrow();
});
test("capability changes clear old selectors but preserve money and billing units", () => {
const draft = initialChannelModelValues(null, protocols);
const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, imageCount: 2 };
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "video", providerModelKey: "gpt-image-2", priceTiers: [tier] }, protocols);
expect(next.protocol).toBe("image");
expect(next.capabilityConfig).toEqual(defaultModelCapabilityConfig("image", "gpt-image-2"));
expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, imageCount: 0 });
expect(tier.operation).toBe("text_to_video");
expect(() => validateChannelModelPrices(next)).toThrow("按秒");
});
test("audio does not retain image/video configuration", () => {
const draft = initialChannelModelValues(null, protocols);
expect(changeChannelModelCapability({ ...draft, capability: "audio" }, protocols).capabilityConfig).toBeUndefined();
});
test("validation errors map to their mounted tab", () => {
expect(editorSectionForField(["priceTiers", 2, "unitPrice"])).toBe("pricing");
expect(editorSectionForField(["capabilityConfig"])).toBe("capabilities");
expect(editorSectionForField(["protocol"])).toBe("identity");
});
});
describe("pricing write validation", () => {
const draft = initialChannelModelValues(null, protocols);
test("accepts an explicit free/default price", () => expect(() => validateChannelModelPrices(draft)).not.toThrow());
test("requires prices and exactly one or zero fallback tiers", () => {
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [] })).toThrow("至少");
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier(), defaultPriceTier()] })).toThrow("只能");
});
test("rejects empty or stale advanced selectors", () => {
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier("advanced")] })).toThrow("匹配条件");
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier("advanced"), operation: "text_to_video" }] })).toThrow("不匹配");
});
test("rejects invalid active prices rather than silently sending zero", () => {
for (const unitPrice of [NaN, Infinity, -1, 1000001, null, undefined]) {
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值");
}
});
test("a new protocol cannot silently convert Token pricing to per request", () => {
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
});
test("validates all text Token prices and the video Token minimum", () => {
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", inputTokenPrice: NaN }] })).toThrow();
const video = { ...draft, capability: "video" as const, protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token" as const }] };
expect(() => validateChannelModelPrices(video)).toThrow("至少为");
video.priceTiers[0].outputTokenPrice = 0.000001;
expect(() => validateChannelModelPrices(video)).not.toThrow();
});
});
test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => {
const router = await Bun.file(new URL("../src/router.tsx", import.meta.url)).text();
const shell = await Bun.file(new URL("../src/pages/admin/components/admin-shell.tsx", import.meta.url)).text();
const pages = await Bun.file(new URL("../src/pages/admin/admin-route-pages.tsx", import.meta.url)).text();
const css = await Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text();
const admin = router.slice(router.indexOf('path: "/admin"'), router.indexOf('{ path: "*"'));
expect(admin.match(/deferred\(/g)).toHaveLength(1);
expect(shell).toMatch(/<Suspense[^]*?<Outlet \/>\s*<\/Suspense>/);
expect(pages).not.toContain("<Suspense");
expect(css).not.toContain("admin-page-enter");
});