+ footer={
+
+
更改实时保存到本地渠道配置
+
}
- loading={testingModel === activeModel}
- disabled={Boolean(testingModel && testingModel !== activeModel)}
- onClick={() => void testModel(activeModel, activeCapability, activeProtocol)}
+ icon={}
+ loading={Boolean(testingModel)}
+ disabled={!activeProtocol || protocolLoading || Boolean(protocolError)}
+ onClick={() => { if (activeModel && activeProtocol) void testModel(activeModel, activeCapability, activeProtocol); }}
>
测试模型
-
+
- ) : (
-
- )
- }
- footer={
-
-
-
- 更改已实时自动保存到渠道配置
-
-
}
- >
- {activeModel ? (
-
-
-
模型能力与请求协议
-
这些设置只影响当前渠道的这个模型,保存后会同步到生成校验。
-
-
- 模型能力
- {
- const nextProtocol = availableProtocols.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || availableProtocols.find((item) => item.capability === nextCapability && item.enabled !== false)?.value || defaultProtocolForCapability(nextCapability, availableProtocols);
- updateCost(activeModel, {
- protocol: nextProtocol,
- capability: nextCapability,
- billingMode: activeBillingMode === "per_second" && nextCapability === "video" ? "per_second" : activeBillingMode === "token" && modelProtocolSupportsTokenBilling(nextCapability, nextProtocol) ? "token" : "fixed_request",
- capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
- });
- }}
- />
-
- {availableProtocols.length ? (
+ items={activeModel ? [
+ {
+ key: "protocol",
+ label: "基本信息",
+ children:
+
+ 模型能力
+
+ block
+ options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]}
+ value={activeCapability}
+ onChange={(nextCapability) => {
+ const nextProtocol = availableProtocols.find((item) => item.value === activeProtocol && item.capability === nextCapability)?.value || availableProtocols.find((item) => item.capability === nextCapability && item.enabled !== false)?.value || defaultProtocolForCapability(nextCapability, availableProtocols);
+ updateCost(activeModel, {
+ protocol: nextProtocol,
+ capability: nextCapability,
+ billingMode: "fixed_request",
+ unitPriceMicrocredits: 0,
+ capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
+ });
+ }}
+ />
+
请求协议
- updateCost(activeModel, {
protocol: nextProtocol,
- billingMode: activeBillingMode === "token" && !modelProtocolSupportsTokenBilling(activeCapability, nextProtocol) ? "fixed_request" : activeBillingMode,
+ billingMode: "fixed_request",
+ unitPriceMicrocredits: 0,
capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
})}
/>
- ) : null}
- {activeCapability === "video" ? (
-
-
计费方式
-
- updateCost(activeModel, { billingMode: value as ModelCost["billingMode"] })}
- />
- updateCost(activeModel, activeBillingMode === "token" ? { outputTokenPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) } : { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })}
- />
-
- {activeBillingMode === "token" ?
按火山方舟任务查询响应的 usage.completion_tokens 结算。
: null}
-
- ) : null}
- {activeCapability === "image" || activeCapability === "video" ? (
-
updateCost(activeModel, { capabilityConfig })}
- />
- ) : null}
-
- ) : null}
-
+
,
+ },
+ {
+ key: "capabilities",
+ label: "能力与参数",
+ children:
+ {activeCapability === "image" || activeCapability === "video" ? (
+
updateCost(activeModel, { capabilityConfig })}
+ />
+ ) : 当前模型类型无需额外配置引用与参数。
}
+ ,
+ },
+ ] : []}
+ />
);
}
@@ -312,10 +261,7 @@ function defaultProtocolForCapability(capability: ModelCapabilityChoice, availab
return fallbackMap[capability] || "chat-completion";
}
-function defaultProtocolForModel(channel: ModelChannel, model: string, availableProtocols: ModelProtocolDefinition[] = []): ModelProtocol {
- if (channel.interfaceType) {
- return channel.interfaceType;
- }
+function defaultProtocolForModel(model: string, availableProtocols: ModelProtocolDefinition[] = []): ModelProtocol {
const capability = inferCapabilityFromModel(model);
return defaultProtocolForCapability(capability, availableProtocols);
}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 9a8cf4659..efd8e9de5 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -181,32 +181,32 @@ export const router = createBrowserRouter([
path: "/admin",
element:
{deferred()},
children: [
- { index: true, element: deferred(
) },
- { path: "users", element: deferred(
) },
- { path: "channels", element: deferred(
) },
- { path: "models", element:
{deferred()} },
- { path: "plugins", element: deferred(
) },
- { path: "payments", element: deferred(
) },
- { path: "prompt-templates", element: deferred(
) },
+ { index: true, element:
},
+ { path: "users", element:
},
+ { path: "channels", element:
},
+ { path: "models", element:
},
+ { path: "plugins", element:
},
+ { path: "payments", element:
},
+ { path: "prompt-templates", element:
},
{ path: "storyboard-prompts", element:
},
- { path: "announcements", element: deferred(
) },
- { path: "resources", element: deferred(
) },
- { path: "credit-operations", element: deferred(
) },
- { path: "redemption-codes", element: deferred(
) },
- { path: "logs", element: deferred(
) },
+ { path: "announcements", element:
},
+ { path: "resources", element:
},
+ { path: "credit-operations", element:
},
+ { path: "redemption-codes", element:
},
+ { path: "logs", element:
},
{ path: "settings", element:
},
- { path: "settings/appearance", element: deferred(
) },
- { path: "settings/drawing-engine", element: deferred(
) },
+ { path: "settings/appearance", element:
},
+ { path: "settings/drawing-engine", element:
},
{ path: "settings/concurrency", element:
},
- { path: "settings/runtime-policy", element: deferred(
) },
- { path: "settings/features", element: deferred(
) },
- { path: "settings/access", element: deferred(
) },
- { path: "settings/email", element: deferred(
) },
- { path: "settings/storage", element: deferred(
) },
- { path: "settings/ark-private-assets", element: deferred(
) },
- { path: "settings/response-interception", element: deferred(
) },
- { path: "settings/third-party", element: deferred(
) },
- { path: "settings/system-update", element: deferred(
) },
+ { path: "settings/runtime-policy", element:
},
+ { path: "settings/features", element:
},
+ { path: "settings/access", element:
},
+ { path: "settings/email", element:
},
+ { path: "settings/storage", element:
},
+ { path: "settings/ark-private-assets", element:
},
+ { path: "settings/response-interception", element:
},
+ { path: "settings/third-party", element:
},
+ { path: "settings/system-update", element:
},
{ path: "settings/libtv", element:
},
],
},
diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css
index e24744f88..f819bb402 100644
--- a/web/src/styles/admin-ui.css
+++ b/web/src/styles/admin-ui.css
@@ -1348,17 +1348,6 @@
position: relative;
}
-@keyframes admin-page-enter {
- from {
- opacity: 0;
- transform: translateY(4px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
.admin-shell .admin-nav-group-label {
display: flex;
align-items: center;
@@ -8936,31 +8925,75 @@
border-color: var(--control-check-fg) !important;
}
-.admin-model-editor-drawer .ant-drawer-header {
- min-height: 52px;
- padding-inline: 24px;
+/* Model editor: one fixed-height dialog, one scrolling panel, persistent actions. */
+.admin-model-editor-modal .ant-modal-container {
+ display: flex;
+ height: min(880px, calc(100dvh - 48px));
+ flex-direction: column;
}
-.admin-model-editor-drawer .ant-drawer-body {
- padding: 20px 24px 24px;
+.admin-model-editor-modal .ant-modal-body {
+ display: flex;
+ overflow: hidden;
}
+.admin-model-editor-title p {
+ margin-top: 4px;
+ color: var(--admin-text-muted);
+ font-size: 13px;
+ font-weight: 400;
+ overflow-wrap: anywhere;
+}
+
+.admin-model-editor-form > .ant-tabs {
+ height: 100%;
+ min-height: 0;
+}
+
+.admin-model-editor-form > .ant-tabs > .ant-tabs-nav {
+ flex-shrink: 0;
+ margin: 0;
+ padding-inline: 28px;
+}
+
+.admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder {
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+
+.admin-model-editor-tab-content {
+ display: grid;
+ gap: 16px;
+ padding: 16px 24px 20px;
+}
+
+.admin-model-protocol-field {
+ grid-column: 1 / -1;
+}
+
+
.admin-model-editor-form {
width: 100%;
- max-width: 1120px;
- margin-inline: auto;
+ min-width: 0;
+ min-height: 0;
}
.admin-model-editor-section {
display: grid;
- grid-template-columns: minmax(140px, 176px) minmax(0, 1fr);
- gap: 24px;
- padding: 20px 22px;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 12px;
+}
+
+.admin-model-editor-section + .admin-model-editor-section {
+ border-top: 1px solid var(--admin-divider);
+ padding-top: 16px;
}
.admin-model-editor-section-stacked {
grid-template-columns: minmax(0, 1fr);
- gap: 14px;
+ gap: 12px;
}
.admin-model-editor-section-heading,
@@ -8968,7 +9001,15 @@
min-width: 0;
}
+.admin-model-editor-section-heading {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+}
+
.admin-model-editor-section-heading h2 {
+ margin: 0;
color: var(--foreground);
font-size: 15px;
font-weight: 650;
@@ -8976,9 +9017,9 @@
}
.admin-model-editor-section-heading p {
- margin-top: 6px;
- color: color-mix(in srgb, var(--foreground) 46%, transparent);
- font-size: 11px;
+ margin: 0;
+ color: var(--admin-text-muted);
+ font-size: 13px;
line-height: 1.65;
}
@@ -8991,34 +9032,47 @@
overflow: hidden;
}
-.admin-model-identity-grid {
+.admin-model-identity-grid,
+.admin-model-protocol-grid {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 16px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px 16px;
}
-.admin-model-editor-drawer .admin-capability-editor {
+.admin-model-editor-modal .ant-form-item {
+ margin-bottom: 0;
+}
+
+.admin-model-editor-modal .ant-form-item .ant-form-item-label {
+ padding-bottom: 4px;
+}
+
+.admin-model-protocol-grid > .ant-alert {
+ grid-column: 1 / -1;
+}
+
+.admin-model-editor-modal .admin-capability-editor {
border-radius: 0;
background: transparent;
padding: 0;
}
-.admin-model-editor-drawer .admin-capability-editor-heading {
+.admin-model-editor-modal .admin-capability-editor-heading {
padding: 0 2px 2px;
}
-.admin-model-editor-drawer .admin-capability-group {
+.admin-model-editor-modal .admin-capability-group {
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
background: color-mix(in srgb, var(--foreground) 2.5%, var(--admin-layer-1));
padding: 14px;
}
-.admin-model-editor-drawer .admin-capability-group > summary {
+.admin-model-editor-modal .admin-capability-group > summary {
position: relative;
padding-right: 20px;
}
-.admin-model-editor-drawer .admin-capability-group > summary::after {
+.admin-model-editor-modal .admin-capability-group > summary::after {
position: absolute;
top: 5px;
right: 2px;
@@ -9031,22 +9085,22 @@
transition: transform 140ms ease;
}
-.admin-model-editor-drawer .admin-capability-group:not([open]) > summary::after {
+.admin-model-editor-modal .admin-capability-group:not([open]) > summary::after {
transform: rotate(-45deg);
}
-.admin-model-editor-drawer .admin-capability-group-description {
+.admin-model-editor-modal .admin-capability-group-description {
color: var(--admin-text-muted) !important;
line-height: 1.5;
}
-.admin-model-editor-drawer .admin-capability-block + .admin-capability-block {
+.admin-model-editor-modal .admin-capability-block + .admin-capability-block {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--admin-divider);
}
-.admin-model-editor-drawer .admin-capability-block-title {
+.admin-model-editor-modal .admin-capability-block-title {
margin-bottom: 9px;
color: var(--admin-text-secondary);
font-size: 10px;
@@ -9054,141 +9108,68 @@
letter-spacing: 0.08em;
}
-.admin-model-editor-drawer .admin-capability-number-field,
-.admin-model-editor-drawer .admin-capability-boolean-field,
-.admin-model-editor-drawer .admin-capability-parameter-field {
+.admin-model-editor-modal .admin-capability-number-field,
+.admin-model-editor-modal .admin-capability-boolean-field,
+.admin-model-editor-modal .admin-capability-parameter-field {
min-width: 0;
}
-.admin-model-editor-drawer .admin-capability-number-field .ant-input-number {
+.admin-model-editor-modal .admin-capability-number-field .ant-input-number {
width: 100% !important;
min-width: 0;
}
-.admin-capability-reference-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
+.admin-model-editor-references .admin-capability-reference-grid {
+ align-items: start;
+ gap: 10px;
}
-.admin-capability-reference-grid.is-three {
- grid-template-columns: repeat(3, minmax(0, 1fr));
+.admin-model-editor-references .admin-capability-reference-card {
+ padding: 10px 12px;
}
-.admin-capability-reference-grid.is-two {
- grid-template-columns: repeat(2, minmax(0, 1fr));
+.admin-model-editor-references .admin-capability-reference-card-heading {
+ min-height: 0;
+ margin-bottom: 6px;
+ padding-bottom: 6px;
}
-.admin-capability-reference-card,
-.admin-capability-protocol-card {
- min-width: 0;
- border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--foreground) 2.5%, var(--admin-layer-1));
- padding: 14px;
+.admin-model-editor-references .admin-capability-reference-fields {
+ gap: 6px;
}
-.admin-capability-reference-card-heading {
- min-height: 38px;
- margin-bottom: 8px;
- padding-bottom: 8px;
- border-bottom: 1px solid var(--admin-divider);
+.admin-model-editor-references .admin-capability-number-field {
+ min-height: 32px;
+ grid-template-columns: minmax(0, 1fr) 80px;
+ align-items: center;
+ gap: 8px;
+ border-bottom: 0;
+ padding: 0;
}
-.admin-capability-reference-card-heading h3,
-.admin-capability-protocol-card-heading h3 {
- color: var(--admin-text-primary);
- font-size: 12px;
- font-weight: 650;
- line-height: 1.4;
+.admin-model-editor-references .admin-capability-boolean-field {
+ min-height: 32px;
+ margin-top: 0;
+ gap: 6px;
+ border: 0;
+ border-radius: 0;
+ padding: 0;
}
-.admin-capability-reference-card-heading p,
-.admin-capability-protocol-card-heading p {
- margin-top: 3px;
- color: var(--admin-text-muted);
- font-size: 10px;
- line-height: 1.5;
+.admin-model-editor-references .admin-capability-boolean-field > div:last-child {
+ gap: 8px;
}
-.admin-capability-reference-fields {
- display: grid;
+.admin-model-editor-references .admin-capability-boolean-field label {
+ display: flex;
+ align-items: center;
gap: 4px;
}
-.admin-capability-reference-fields .admin-capability-number-field {
- display: grid;
- min-height: 62px;
- grid-template-columns: minmax(0, 1fr);
- align-items: end;
- gap: 5px;
- border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
- padding-block: 4px;
-}
-
-.admin-capability-reference-fields .admin-capability-number-field:last-child {
- border-bottom: 0;
-}
-
-.admin-capability-reference-fields .admin-capability-field-label {
- margin-bottom: 0;
- line-height: 1.4;
-}
-
-.admin-capability-reference-fields .admin-capability-boolean-field {
- min-height: 48px;
- margin-top: 4px;
- padding-block: 6px;
-}
-
-.admin-capability-reference-fields .admin-capability-boolean-field > div:first-child > div:last-child {
- display: none;
-}
-
-.admin-capability-protocol-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
-}
-
-.admin-capability-protocol-card-heading {
- display: flex;
- min-height: 46px;
- align-items: flex-start;
- gap: 9px;
- margin-bottom: 12px;
- padding-bottom: 10px;
- border-bottom: 1px solid var(--admin-divider);
-}
-
-.admin-capability-protocol-card-heading > span {
- display: grid;
- width: 24px;
- height: 24px;
- flex: 0 0 auto;
- place-items: center;
- border-radius: 6px;
- background: var(--admin-layer-3);
- color: var(--admin-text-muted);
- font-size: 9px;
- font-weight: 700;
-}
-
-.admin-capability-protocol-card-heading p {
- margin-top: 2px;
- line-height: 1.45;
-}
-
-.admin-capability-protocol-fields,
-.admin-capability-spec-grid {
- display: grid;
- gap: 10px;
-}
-
-.admin-capability-duration-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
+@media (min-width: 601px) and (max-width: 1000px) {
+ .admin-model-editor-references .admin-capability-reference-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
}
.admin-price-tier-card {
@@ -9201,24 +9182,22 @@
.admin-price-tier-card-header {
display: flex;
min-width: 0;
- align-items: flex-start;
+ align-items: center;
justify-content: space-between;
- gap: 16px;
+ gap: 12px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- padding: 12px 14px;
+ padding: 8px 12px;
}
.admin-price-tier-card-body {
display: grid;
- grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
+ gap: 12px 16px;
+ padding: 12px;
}
.admin-price-tier-block {
- padding: 14px;
-}
-
-.admin-price-tier-block + .admin-price-tier-block {
- border-left: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
+ min-width: 0;
}
.admin-price-tier-block-title {
@@ -9235,26 +9214,24 @@
}
.admin-price-tier-match-grid {
+ grid-column: 1 / -1;
display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.admin-price-tier-upstream {
- grid-column: 1 / -1;
+ grid-column: span 2;
}
.admin-price-tier-billing-grid {
display: grid;
- grid-template-columns: minmax(0, 1fr);
- align-items: end;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ align-items: start;
gap: 12px;
}
-.admin-price-tier-billing-mode,
-.admin-price-tier-unit-price,
-.admin-price-tier-token-grid,
-.admin-price-tier-controls {
+.admin-price-tier-token-grid {
grid-column: 1 / -1;
}
@@ -9267,18 +9244,18 @@
.admin-price-tier-billing-mode .ant-segmented {
border: 1px solid var(--admin-card-border);
background: var(--admin-layer-2);
- padding: 3px;
+ padding: 2px;
}
.admin-price-tier-billing-mode .ant-segmented-item {
- min-height: 32px;
+ min-height: 28px;
border-radius: 5px;
}
.admin-price-tier-billing-mode .ant-segmented-item-label {
- min-height: 32px;
+ min-height: 28px;
padding-inline: 12px;
- line-height: 32px;
+ line-height: 28px;
}
.admin-price-tier-billing-mode .ant-segmented-item-selected {
@@ -9291,42 +9268,21 @@
}
.admin-price-tier-controls {
- display: grid !important;
- grid-template-columns: minmax(0, 1fr);
- gap: 10px !important;
+ margin-left: auto;
}
.admin-price-tier-toggle {
display: flex;
min-width: 0;
- min-height: 58px;
- align-items: flex-start;
+ align-items: center;
justify-content: space-between;
gap: 10px;
- border: 1px solid var(--admin-card-border);
- border-radius: 7px;
- background: var(--admin-layer-2);
- padding: 8px 10px;
-}
-
-.admin-price-tier-toggle > div {
- display: grid;
- flex: 1 1 auto;
- min-width: 0;
- gap: 2px;
-}
-
-.admin-price-tier-toggle strong {
- color: var(--admin-text-primary);
- font-size: 11px;
- font-weight: 600;
- line-height: 1.35;
}
.admin-price-tier-toggle span {
overflow-wrap: anywhere;
color: var(--admin-text-muted);
- font-size: 9px;
+ font-size: 12px;
line-height: 1.4;
white-space: normal;
}
@@ -9335,170 +9291,76 @@
min-height: 38px;
}
-.admin-drawer.admin-model-editor-drawer .ant-drawer-footer {
- border-top: 0;
- background: var(--admin-layer-0);
- padding: 8px 24px 12px;
- backdrop-filter: none;
-}
-
.admin-model-editor-footer-actions {
- width: 100%;
- max-width: 1120px;
- min-height: 52px;
- margin-inline: auto;
- border: 1px solid var(--admin-card-border);
- border-radius: 10px;
- background: var(--admin-layer-1);
- padding: 8px 10px;
- box-shadow: 0 8px 24px color-mix(in srgb, var(--foreground) 8%, transparent);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
}
.admin-model-editor-footer-primary {
+ display: flex;
min-width: 0;
+ flex-wrap: wrap;
+ gap: 8px;
}
.admin-model-editor-footer-status {
display: flex;
- min-height: 34px;
align-items: center;
- gap: 8px;
- margin-right: 4px;
- border: 1px solid var(--admin-card-border);
- border-radius: 6px;
- background: var(--admin-layer-2);
- padding: 4px 8px;
-}
-
-.admin-model-editor-footer-status > span {
- width: 6px;
- height: 6px;
- flex: 0 0 auto;
- border-radius: 50%;
- background: var(--admin-text-muted);
-}
-
-.admin-model-editor-footer-status > span.is-enabled {
- background: #10a36a;
+ gap: 10px;
+ text-align: left;
}
.admin-model-editor-footer-status > div {
display: grid;
- min-width: 0;
- margin-right: 4px;
+ gap: 2px;
}
.admin-model-editor-footer-status strong {
color: var(--admin-text-primary);
- font-size: 11px;
+ font-size: 13px;
font-weight: 600;
- line-height: 1.25;
}
.admin-model-editor-footer-status small {
color: var(--admin-text-muted);
- font-size: 9px;
- line-height: 1.2;
+ font-size: 12px;
}
-:root:not(.dark) .admin-model-editor-drawer .ant-drawer-header,
-:root:not(.dark) .admin-model-editor-drawer .ant-drawer-footer {
- border-color: #d4dae2;
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .ant-drawer-body,
-:root:not(.dark) .admin-drawer.admin-model-editor-drawer .ant-drawer-footer {
- background: #edf1f5;
-}
-
-:root:not(.dark) .admin-model-editor-section {
- border: 1px solid #d4dae2;
- background: #ffffff;
- box-shadow: 0 1px 3px rgb(15 23 42 / 0.055);
-}
-
-:root:not(.dark) .admin-model-editor-drawer :is(.admin-capability-group, .admin-capability-boolean-field, .admin-capability-parameter-field),
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card,
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-protocol-card,
-:root:not(.dark) .admin-price-tier-toggle,
-:root:not(.dark) .admin-price-tier-billing-mode .ant-segmented {
- border-color: #cbd3dd;
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-price-tier-card {
- border-color: #cbd3dd;
- background: #f5f7fa;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card,
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-protocol-card {
- border-color: #cbd3dd;
- background: #f5f7fa;
-}
-
-:root:not(.dark) .admin-model-editor-drawer [role="radio"] {
- border-color: #c3ccd7;
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-model-editor-drawer [role="radio"]:hover {
- border-color: #929eac;
- background: #f8fafc;
-}
-
-:root:not(.dark) .admin-model-editor-drawer [role="radio"][aria-checked="true"] {
- border-color: #657180;
- background: #eef3f7;
- box-shadow: inset 0 0 0 1px rgb(31 41 55 / 0.08);
-}
-
-:root:not(.dark) .admin-model-editor-drawer :is(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-select-selector, .ant-segmented) {
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-model-editor-section-heading h2 {
- color: #20262e;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .ant-form-item-label > label {
- color: #364152;
- font-weight: 550;
-}
-
-:root:not(.dark) .admin-model-editor-footer-actions {
- border-color: #d4dae2;
- background: #ffffff;
- box-shadow: 0 6px 20px rgb(15 23 42 / 0.08);
-}
-
-:root:not(.dark) .admin-price-tier-billing-mode .ant-segmented-item-selected {
- background: #e8edf2;
- box-shadow: inset 0 0 0 1px rgb(31 41 55 / 0.12);
+@media (max-width: 600px) {
+ .admin-model-editor-modal .ant-modal-container {
+ height: calc(100dvh - 24px);
+ }
+ .admin-model-editor-tab-content {
+ padding: 12px 16px;
+ }
+ .admin-model-editor-footer-primary {
+ width: 100%;
+ justify-content: flex-end;
+ }
+ .admin-model-editor-modal .admin-capability-reference-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .admin-price-tier-upstream {
+ grid-column: 1 / -1;
+ }
+ .admin-model-identity-grid,
+ .admin-model-protocol-grid,
+ .admin-price-tier-billing-grid,
+ .admin-price-tier-match-grid,
+ .admin-price-tier-token-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
}
@media (max-width: 899px) {
.admin-model-editor-section,
- .admin-capability-protocol-grid,
- .admin-price-tier-card-body,
- .admin-model-identity-grid {
+ .admin-price-tier-card-body {
grid-template-columns: minmax(0, 1fr);
}
- .admin-model-editor-section {
- gap: 16px;
- }
-
- .admin-model-editor-section-heading {
- padding-bottom: 12px;
- border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
- }
-
- .admin-price-tier-block + .admin-price-tier-block {
- border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
- border-left: 0;
- }
}
/* 管理后台可读性基线:收敛历史页面中过低的文字透明度,并统一表单层级。 */
@@ -9654,10 +9516,6 @@
}
@media (min-width: 1024px) {
- .admin-shell .admin-page-root > div {
- animation: admin-page-enter 190ms cubic-bezier(0.16, 1, 0.3, 1) both;
- }
-
.admin-shell
:where(
.admin-sidebar,
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 2a57be437..1a5250a74 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -12237,145 +12237,6 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
overflow-y: auto;
}
-/* 系统渠道模型编辑器:恢复已确认的信息层级、协议参数与价格档布局。 */
-.admin-model-editor-drawer .ant-drawer-header {
- min-height: 52px;
- padding-inline: 24px;
-}
-
-.admin-model-editor-drawer .ant-drawer-body {
- padding: 20px 24px 24px;
-}
-
-.admin-model-editor-form {
- width: 100%;
- max-width: 1120px;
- margin-inline: auto;
-}
-
-.admin-model-editor-section {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- gap: 16px;
- padding: 20px 22px;
-}
-
-.admin-model-editor-section-stacked {
- grid-template-columns: minmax(0, 1fr);
- gap: 14px;
-}
-
-.admin-model-editor-section-heading,
-.admin-model-editor-section-content {
- min-width: 0;
-}
-
-.admin-model-editor-section-heading {
- padding-bottom: 12px;
- border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
-}
-
-.admin-model-editor-section-heading h2 {
- color: var(--foreground);
- font-size: 15px;
- font-weight: 650;
- line-height: 1.4;
-}
-
-.admin-model-editor-section-heading p {
- margin-top: 6px;
- color: color-mix(in srgb, var(--foreground) 46%, transparent);
- font-size: 11px;
- line-height: 1.65;
-}
-
-.admin-model-editor-section-content > .ant-form-item:last-child,
-.admin-model-editor-section-content > .ant-form-item-row:last-child {
- margin-bottom: 0;
-}
-
-.admin-model-editor-parameters .admin-model-editor-section-content {
- overflow: hidden;
-}
-
-.admin-model-identity-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 16px;
-}
-
-.admin-model-identity-grid-with-icon {
- grid-template-columns: repeat(4, minmax(0, 1fr));
-}
-
-.admin-model-editor-drawer .admin-capability-editor {
- border-radius: 0;
- background: transparent;
- padding: 0;
-}
-
-.admin-model-editor-drawer .admin-capability-editor-heading {
- padding: 0 2px 2px;
-}
-
-.admin-model-editor-drawer .admin-capability-group {
- border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
- background: color-mix(in srgb, var(--foreground) 2.5%, var(--admin-layer-1));
- padding: 14px;
-}
-
-.admin-model-editor-drawer .admin-capability-group > summary {
- position: relative;
- padding-right: 20px;
-}
-
-.admin-model-editor-drawer .admin-capability-group > summary::after {
- position: absolute;
- top: 5px;
- right: 2px;
- width: 7px;
- height: 7px;
- border-right: 1px solid currentColor;
- border-bottom: 1px solid currentColor;
- content: "";
- transform: rotate(45deg);
- transition: transform 140ms ease;
-}
-
-.admin-model-editor-drawer .admin-capability-group:not([open]) > summary::after {
- transform: rotate(-45deg);
-}
-
-.admin-model-editor-drawer .admin-capability-group-description {
- color: var(--admin-text-muted) !important;
- line-height: 1.5;
-}
-
-.admin-model-editor-drawer .admin-capability-block + .admin-capability-block {
- margin-top: 14px;
- padding-top: 14px;
- border-top: 1px solid var(--admin-divider);
-}
-
-.admin-model-editor-drawer .admin-capability-block-title {
- margin-bottom: 9px;
- color: var(--admin-text-secondary);
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.08em;
-}
-
-.admin-model-editor-drawer .admin-capability-number-field,
-.admin-model-editor-drawer .admin-capability-boolean-field,
-.admin-model-editor-drawer .admin-capability-parameter-field {
- min-width: 0;
-}
-
-.admin-model-editor-drawer .admin-capability-number-field .ant-input-number {
- width: 100% !important;
- min-width: 0;
-}
-
.admin-capability-reference-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -12502,303 +12363,10 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
gap: 10px;
}
-.admin-price-tier-card {
- overflow: hidden;
- border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
- border-radius: 8px;
- background: color-mix(in srgb, var(--foreground) 2%, var(--admin-layer-1));
-}
-
-.admin-price-tier-card-header {
- display: flex;
- min-width: 0;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- border-bottom: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
- padding: 12px 14px;
-}
-
-.admin-price-tier-card-body {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
-}
-
-.admin-price-tier-block {
- padding: 14px;
-}
-
-.admin-price-tier-block + .admin-price-tier-block {
- border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
-}
-
-.admin-price-tier-block-title {
- margin-bottom: 10px;
- color: color-mix(in srgb, var(--foreground) 48%, transparent);
- font-size: 10px;
- font-weight: 650;
- letter-spacing: 0.08em;
-}
-
-.admin-price-tier-block-title > span {
- margin-right: 5px;
- color: var(--admin-text-muted);
-}
-
-.admin-price-tier-match-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.admin-price-tier-upstream {
- grid-column: 1 / -1;
-}
-
-.admin-price-tier-billing-grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- align-items: end;
- gap: 12px;
-}
-
-.admin-price-tier-billing-mode,
-.admin-price-tier-unit-price,
-.admin-price-tier-token-grid,
-.admin-price-tier-controls {
- grid-column: 1 / -1;
-}
-
-.admin-price-tier-token-grid {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
-}
-
-.admin-price-tier-billing-mode .ant-segmented {
- border: 1px solid var(--admin-card-border);
- background: var(--admin-layer-2);
- padding: 3px;
-}
-
-.admin-price-tier-billing-mode .ant-segmented-item {
- min-height: 32px;
- border-radius: 5px;
-}
-
-.admin-price-tier-billing-mode .ant-segmented-item-label {
- min-height: 32px;
- padding-inline: 12px;
- line-height: 32px;
-}
-
-.admin-price-tier-billing-mode .ant-segmented-item-selected {
- background: var(--admin-layer-1);
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 14%, transparent);
-}
-
-.admin-price-tier-unit-price .ant-input-number {
- width: 100% !important;
-}
-
-.admin-price-tier-controls {
- display: grid !important;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px !important;
-}
-
-.admin-price-tier-toggle {
- display: flex;
- min-width: 0;
- min-height: 58px;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- border: 1px solid var(--admin-card-border);
- border-radius: 7px;
- background: var(--admin-layer-2);
- padding: 8px 10px;
-}
-
-.admin-price-tier-toggle > div {
- display: grid;
- min-width: 0;
- gap: 2px;
-}
-
-.admin-price-tier-toggle strong {
- color: var(--admin-text-primary);
- font-size: 11px;
- font-weight: 600;
- line-height: 1.35;
-}
-
-.admin-price-tier-toggle span {
- overflow: hidden;
- color: var(--admin-text-muted);
- font-size: 9px;
- line-height: 1.4;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.admin-model-editor-add-tier.ant-btn {
- min-height: 38px;
-}
-
-.admin-drawer.admin-model-editor-drawer .ant-drawer-footer {
- border-top: 0;
- background: var(--admin-layer-0);
- padding: 8px 24px 12px;
- backdrop-filter: none;
-}
-
-.admin-model-editor-footer-actions {
- width: 100%;
- max-width: 1120px;
- min-height: 52px;
- margin-inline: auto;
- border: 1px solid var(--admin-card-border);
- border-radius: 10px;
- background: var(--admin-layer-1);
- padding: 8px 10px;
- box-shadow: 0 8px 24px color-mix(in srgb, var(--foreground) 8%, transparent);
-}
-
-.admin-model-editor-footer-primary {
- min-width: 0;
-}
-
-.admin-model-editor-footer-status {
- display: flex;
- min-height: 34px;
- align-items: center;
- gap: 8px;
- margin-right: 4px;
- border: 1px solid var(--admin-card-border);
- border-radius: 6px;
- background: var(--admin-layer-2);
- padding: 4px 8px;
-}
-
-.admin-model-editor-footer-status > span {
- width: 6px;
- height: 6px;
- flex: 0 0 auto;
- border-radius: 50%;
- background: var(--admin-text-muted);
-}
-
-.admin-model-editor-footer-status > span.is-enabled {
- background: #10a36a;
-}
-
-.admin-model-editor-footer-status > div {
- display: grid;
- min-width: 0;
- margin-right: 4px;
-}
-
-.admin-model-editor-footer-status strong {
- color: var(--admin-text-primary);
- font-size: 11px;
- font-weight: 600;
- line-height: 1.25;
-}
-
-.admin-model-editor-footer-status small {
- color: var(--admin-text-muted);
- font-size: 9px;
- line-height: 1.2;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .ant-drawer-header,
-:root:not(.dark) .admin-model-editor-drawer .ant-drawer-footer {
- border-color: #d4dae2;
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .ant-drawer-body,
-:root:not(.dark) .admin-drawer.admin-model-editor-drawer .ant-drawer-footer {
- background: #edf1f5;
-}
-
-:root:not(.dark) .admin-model-editor-section {
- border: 1px solid #d4dae2;
- background: #ffffff;
- box-shadow: 0 1px 3px rgb(15 23 42 / 0.055);
-}
-
-:root:not(.dark) .admin-model-editor-drawer :is(.admin-capability-group, .admin-capability-boolean-field, .admin-capability-parameter-field),
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card,
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-protocol-card,
-:root:not(.dark) .admin-price-tier-toggle,
-:root:not(.dark) .admin-price-tier-billing-mode .ant-segmented {
- border-color: #cbd3dd;
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-price-tier-card {
- border-color: #cbd3dd;
- background: #f5f7fa;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card,
-:root:not(.dark) .admin-model-editor-drawer .admin-capability-protocol-card {
- border-color: #cbd3dd;
- background: #f5f7fa;
-}
-
-:root:not(.dark) .admin-model-editor-drawer [role="radio"] {
- border-color: #c3ccd7;
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-model-editor-drawer [role="radio"]:hover {
- border-color: #929eac;
- background: #f8fafc;
-}
-
-:root:not(.dark) .admin-model-editor-drawer [role="radio"][aria-checked="true"] {
- border-color: #657180;
- background: #eef3f7;
- box-shadow: inset 0 0 0 1px rgb(31 41 55 / 0.08);
-}
-
-:root:not(.dark) .admin-model-editor-drawer
- :is(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-select-selector, .ant-segmented) {
- background: #ffffff;
-}
-
-:root:not(.dark) .admin-model-editor-section-heading h2 {
- color: #20262e;
-}
-
-:root:not(.dark) .admin-model-editor-drawer .ant-form-item-label > label {
- color: #364152;
- font-weight: 550;
-}
-
-:root:not(.dark) .admin-model-editor-footer-actions {
- border-color: #d4dae2;
- background: #ffffff;
- box-shadow: 0 6px 20px rgb(15 23 42 / 0.08);
-}
-
-:root:not(.dark) .admin-price-tier-billing-mode .ant-segmented-item-selected {
- background: #e8edf2;
- box-shadow: inset 0 0 0 1px rgb(31 41 55 / 0.12);
-}
-
@media (max-width: 899px) {
- .admin-model-editor-section,
- .admin-capability-protocol-grid,
- .admin-price-tier-card-body,
- .admin-model-identity-grid,
- .admin-model-identity-grid-with-icon {
+ .admin-capability-protocol-grid {
grid-template-columns: minmax(0, 1fr);
}
-
}
/* 管理后台可读性基线:收敛历史页面中过低的文字透明度,并统一表单层级。 */
diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts
index 28c551ec8..9fcf31b1e 100644
--- a/web/test/admin-ui-regressions.test.ts
+++ b/web/test/admin-ui-regressions.test.ts
@@ -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, '
{
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)");
diff --git a/web/test/canvas-grid-viewport.test.tsx b/web/test/canvas-grid-viewport.test.tsx
new file mode 100644
index 000000000..27155002b
--- /dev/null
+++ b/web/test/canvas-grid-viewport.test.tsx
@@ -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(
+ ()} viewport={viewport} onViewportChange={() => {}} backgroundMode={mode} appearance={canvasAppearanceForTheme(theme)}>
+
+ ,
+ );
+ return markup.match(/]*>/)?.[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([["--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).detail));
+ container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, (event) => previews.push((event as CustomEvent).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([]);
+ });
+});
diff --git a/web/test/channel-model-editor-form.test.ts b/web/test/channel-model-editor-form.test.ts
new file mode 100644
index 000000000..11c18778e
--- /dev/null
+++ b/web/test/channel-model-editor-form.test.ts
@@ -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(/\s*<\/Suspense>/);
+ expect(pages).not.toContain("