diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 7dbf568b5..0125c5978 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -14,6 +14,19 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 线上故障时段取证及灰度效果确认;本次没有部署、清缓存或重投任务,不能认定早间事故已修复。 - [ ] 独立 worker、可靠投递、渠道前置队列、全部回调 owner 校验、业务 Redis 缓存、请求链路日志和事件驱动 SSE 属于后续阶段。范围、上线风险及验收步骤见“开发文档 → 任务缓存与队列稳定性”。 +## 后台模型编辑与紧凑布局 + +- [x] 模型编辑改为固定操作栏的高弹窗,按基本信息、能力与参数、积分定价分组;隐藏页签校验失败时定位到对应页签,未保存修改关闭前确认。 +- [x] 基本信息采用双列字段,次要说明收进提示;价格档的定价方式、计费方式与金额横向排列,规格条件单独展开,启用状态收进档位标题栏。 +- [x] 引用与限制改为左侧标签、右侧数字框,支持/默认开关横排,压缩卡片头部和行距,卡片按内容高度展示;样式限定后台编辑弹窗,不改变设置页的共享组件布局。 +- [x] 调用协议由下拉框改为固定高度的内嵌协议浏览器:支持按协议名称、厂商和请求路径搜索,以单选列表切换,右侧持续展示当前协议的请求、请求体、响应方式和来源;约 30 个视频协议下仍保持弹窗高度稳定。 +- [x] 管理后台“前台模型”和用户端“自定义渠道”的新增/编辑统一为居中分组弹窗;前台模型的供应线路改为可搜索单选浏览器。自定义渠道把连接信息、模型与能力合并为一个可滚动页面,不显示页签;调用协议仅在渠道内模型的“编辑模型使用配置”中选择,用户自定义模型不提供积分定价。 +- [x] 隔离组件验收已覆盖前台模型新增、类型切换、线路添加与详情联动,以及自定义渠道新增、完成后重新编辑、渠道内模型协议搜索切换和嵌套编辑;验收适配窄屏单列布局,未向后端写入测试数据。统一改造后的 46 项相关回归测试和前端生产构建通过。 +- [x] 协议选择专项与后台 UI 回归共 27 项通过,前端生产构建通过;浏览器已确认当前协议选中态、协议详情和 `sora` 搜索筛选正常,未执行协议切换或保存。 +- [x] 31 项相关回归测试和前端构建通过。浏览器验证引用数量字段可编辑并恢复原值,未支持参数的默认开关仍禁用;桌面与 390px 窄屏无横向溢出,底部操作栏可见、长内容可滚动。未保存模型或触发收费上游测试。 +- [ ] 人工确认紧凑布局、浅色主题、新增/编辑实际保存、协议切换和规格计费组合;当前真实路由受前台模型与自定义渠道功能开关限制,本轮只在隔离组件页完成交互验收。 +- [ ] 后台切页已移除入场透明度动画并收敛为持久布局内的 Suspense 边界;冷/热路由切换的闪屏效果仍需浏览器复验。 + ## 邮箱域名白名单与公告自动提醒(PR #417) - 新增注册邮箱域名白名单:默认常见服务商、空列表不限制、精确域名匹配;发送注册验证码及提交注册都会校验,已有账号找回密码不受限制。保存失败不覆盖既有配置。 @@ -230,6 +243,9 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 ## 自由空白画布与画布外观 +- [x] 线网格与点阵改为屏幕坐标下的固定间距背景,移除缩放时的密度、点大小和偏移更新;节点与连线仍按原视口平移缩放,网格颜色和透明度不变。 +- [x] 固定背景新增渲染与实时视口回归测试,覆盖缩放范围、原点阵阈值、正负平移及空白模式;相关测试分组运行共 49 项通过,前端生产构建通过(保留既有分包警告)。 +- [ ] 在浅色、深色及自定义主题下连续滚轮缩放、触控板捏合与拖动画布,确认背景无密度跳变或闪烁;切换空白/线网格/点阵后复验,确认节点与连线位置正常。 - [x] 新建画布引导新增“自由空白画布”主入口;进入后显示中央空白起始框和加号,不创建占位节点。 - [x] 中央加号复用底部工具栏的添加命令,当前浏览器已确认文本、绘图、分镜、背板、文件夹、图片、视频、导演台、音频、工作流、上传文件和素材库入口完整显示。 - [x] 自由起点按画布持久化,当前浏览器刷新后仍停留在自由空白起始框;浅色、深色两种主题已完成常规桌面尺寸验收。 diff --git a/web/src/components/canvas/infinite-canvas.tsx b/web/src/components/canvas/infinite-canvas.tsx index e2b8ab938..2639dae68 100644 --- a/web/src/components/canvas/infinite-canvas.tsx +++ b/web/src/components/canvas/infinite-canvas.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from import { resolveCanvasAppearance, resolveCanvasGridColor, type CanvasAppearance } from "@/lib/canvas/canvas-appearance"; import { resolveCanvasPointerIntent } from "@/lib/canvas/canvas-selection"; import type { CanvasBackgroundMode } from "@/lib/canvas-theme"; -import { applyCanvasLiveViewport, canvasDotGridPx, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; +import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ViewportTransform } from "@/types/canvas"; @@ -408,13 +408,6 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM "--canvas-live-inverse-scale": 1 / Math.max(viewport.k, 0.05), "--canvas-committed-scale": viewport.k, "--canvas-live-scale-ratio": 1, - "--canvas-grid-size": `${48 * viewport.k}px`, - "--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`, - "--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`, - "--canvas-dot-grid-size": `${canvasDotGridPx(viewport.k)}px`, - "--canvas-dot-grid-x": `${viewport.x % canvasDotGridPx(viewport.k)}px`, - "--canvas-dot-grid-y": `${viewport.y % canvasDotGridPx(viewport.k)}px`, - "--canvas-dot-size": canvasDotPx(viewport.k), } as React.CSSProperties} onPointerDown={handlePointerDown} onDoubleClick={(event) => { @@ -447,7 +440,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM function CanvasGrid({ appearance, mode }: { appearance?: CanvasAppearance; mode: CanvasBackgroundMode }) { const colorTheme = useThemeStore((state) => state.theme); const gridColor = resolveCanvasGridColor(appearance, colorTheme, mode); - const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${gridColor} var(--canvas-dot-size), transparent calc(var(--canvas-dot-size) + 0.2px))` : `linear-gradient(${gridColor} 1px, transparent 1px), linear-gradient(90deg, ${gridColor} 1px, transparent 1px)`; + const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${gridColor} 0.8px, transparent 1px)` : `linear-gradient(${gridColor} 1px, transparent 1px), linear-gradient(90deg, ${gridColor} 1px, transparent 1px)`; if (mode === "blank") return null; return ( @@ -455,12 +448,11 @@ function CanvasGrid({ appearance, mode }: { appearance?: CanvasAppearance; mode: data-canvas-grid-layer className="pointer-events-none absolute" style={{ - inset: mode === "dots" ? "calc(-1 * var(--canvas-dot-grid-size))" : "calc(-1 * var(--canvas-grid-size))", + // 装饰网格固定在屏幕坐标,避免缩放时改变密度或产生亚像素位移闪烁。 + inset: 0, backgroundImage, - backgroundSize: mode === "dots" ? "var(--canvas-dot-grid-size) var(--canvas-dot-grid-size)" : "var(--canvas-grid-size) var(--canvas-grid-size)", - transform: mode === "dots" ? "translate3d(var(--canvas-dot-grid-x), var(--canvas-dot-grid-y), 0)" : "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)", + backgroundSize: "48px 48px", opacity: mode === "dots" ? 0.34 : 0.46, - willChange: "transform", }} /> ); diff --git a/web/src/components/choice-browser.css b/web/src/components/choice-browser.css new file mode 100644 index 000000000..a914f897f --- /dev/null +++ b/web/src/components/choice-browser.css @@ -0,0 +1,48 @@ +.choice-browser { + container-type: inline-size; + display: grid; + min-width: 0; + gap: 8px; + color: var(--foreground); +} +.choice-browser-panels { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, .65fr); + overflow: hidden; + border: 1px solid var(--workspace-border); + border-radius: var(--r-md); +} +.choice-browser-list { height: 248px; overflow-y: auto; overscroll-behavior: contain; padding: 4px; scrollbar-gutter: stable; } +.choice-browser-option { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 120px) 16px; + align-items: center; + gap: 12px; + min-height: 48px; + padding: 7px 10px; + border-radius: var(--r-sm); + cursor: pointer; +} +.choice-browser-option:hover { background: var(--surface-hover); } +.choice-browser-option[data-selected="true"] { background: var(--workspace-accent-soft); box-shadow: inset 0 0 0 1px var(--workspace-border-strong); } +.choice-browser-option:has(input:disabled) { opacity: .5; cursor: not-allowed; } +.choice-browser-option:has(input:focus-visible) { outline: 2px solid var(--ring); outline-offset: -2px; } +.choice-browser-option input { accent-color: var(--workspace-accent); width: 14px; height: 14px; } +.choice-browser-main { display: grid; min-width: 0; gap: 2px; } +.choice-browser-main strong { font-size: var(--fs-caption); font-weight: 600; overflow-wrap: anywhere; } +.choice-browser-main > span, .choice-browser-vendor { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-label); color: var(--muted-foreground); } +.choice-browser-main > span { font-family: monospace; } +.choice-browser-detail { min-width: 0; max-height: 248px; overflow-y: auto; overscroll-behavior: contain; padding: 16px; border-left: 1px solid var(--workspace-border); } +.choice-browser-detail > span { display: block; margin-bottom: 4px; color: var(--muted-foreground); font-size: var(--fs-label); } +.choice-browser-detail > strong { font-size: var(--fs-caption); overflow-wrap: anywhere; } +.choice-browser-detail dl { display: grid; gap: 8px; margin-top: 12px; font-size: var(--fs-caption); } +.choice-browser-detail dl > div { display: grid; grid-template-columns: 4em minmax(0, 1fr); gap: 8px; } +.choice-browser-detail dt, .choice-browser-detail p { color: var(--muted-foreground); } +.choice-browser-detail dd, .choice-browser-detail p { margin: 0; overflow-wrap: anywhere; } +.choice-browser-state { display: grid; min-height: 100px; place-items: center; color: var(--muted-foreground); font-size: var(--fs-caption); padding: 16px; } +@container (max-width: 620px) { + .choice-browser-panels { grid-template-columns: minmax(0, 1fr); } + .choice-browser-detail { border-left: 0; border-top: 1px solid var(--workspace-border); } + .choice-browser-option { grid-template-columns: minmax(0, 1fr) 16px; } + .choice-browser-vendor { display: none; } +} diff --git a/web/src/components/choice-browser.tsx b/web/src/components/choice-browser.tsx new file mode 100644 index 000000000..aaf50f6b3 --- /dev/null +++ b/web/src/components/choice-browser.tsx @@ -0,0 +1,117 @@ +import { Input } from "antd"; +import { Search } from "lucide-react"; +import { useId, useState, type ReactNode } from "react"; +import "./choice-browser.css"; + +export type BrowserChoice = { + value: string; + label: string; + subtitle?: string; + vendor?: string; + searchText?: string; + disabled?: boolean; + details: Array<{ label: string; value: ReactNode }>; +}; + +/** A controlled form field: searching never changes the selected value. */ +export function ChoiceBrowser({ + value, + onChange, + options, + label, + placeholder = "搜索名称、来源或请求路径", + loading = false, + error = "", + disabled = false, + id, + ...aria +}: { + value?: string; + onChange?: (value: string) => void; + options: BrowserChoice[]; + label: string; + placeholder?: string; + loading?: boolean; + error?: string; + disabled?: boolean; + id?: string; + "aria-describedby"?: string; + "aria-invalid"?: boolean | "true" | "false"; +}) { + const name = useId(); + const [query, setQuery] = useState(""); + const selected = options.find((option) => option.value === value); + const normalizedQuery = query.trim().toLowerCase(); + const visible = options.filter((option) => + [option.label, option.subtitle, option.vendor, option.searchText].join(" ").toLowerCase().includes(normalizedQuery), + ); + const unavailable = disabled || loading || Boolean(error); + + return ( +
+ } + placeholder={placeholder} + value={query} + onChange={(event) => setQuery(event.target.value)} + /> + {loading || error || !options.length ? ( +
+ {loading ? "正在读取可选项…" : error || "暂无可用选项"} +
+ ) : ( +
+
+ {visible.map((option) => ( + + ))} + {!visible.length && ( +
+ 没有匹配的选项 +
+ )} +
+
+ 当前选择 + {selected ? ( + <> + {selected.label} +
+ {selected.details.map((detail) => ( +
+
{detail.label}
+
{detail.value}
+
+ ))} +
+ + ) : ( +

{value ? `当前配置 ${value} 不在可用目录中,请重新选择。` : "选择左侧选项查看详情"}

+ )} +
+
+ )} +
+ ); +} diff --git a/web/src/components/model-editor-modal.css b/web/src/components/model-editor-modal.css new file mode 100644 index 000000000..40b81226c --- /dev/null +++ b/web/src/components/model-editor-modal.css @@ -0,0 +1,23 @@ +.model-editor-modal .ant-modal-container { display: flex; flex-direction: column; height: min(880px, calc(100dvh - 48px)); } +.model-editor-modal .ant-modal-header, .model-editor-modal .ant-modal-footer { padding: 18px 24px; flex-shrink: 0; } +.model-editor-modal .ant-modal-footer { border-top: 1px solid var(--workspace-border); } +.model-editor-modal .ant-modal-body { display: flex; flex-direction: column; overflow: hidden; } +.model-editor-modal .ant-modal-body > .ant-form { display: flex; flex-direction: column; height: 100%; min-height: 0; } +.model-editor-modal .ant-tabs { display: flex; flex: 1; min-height: 0; } +.model-editor-modal .ant-tabs-nav { flex-shrink: 0; margin: 0; padding-inline: 24px; } +.model-editor-modal .ant-tabs-body-holder { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.model-editor-modal .ant-modal-body > .model-editor-panel { flex: 1; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.model-editor-panel { display: grid; gap: 16px; padding: 20px 24px; min-width: 0; } +.model-editor-panel .ant-form-item { margin-bottom: 0; } +.model-editor-connection-fields > .ant-form-item { grid-column: auto; min-width: 0; } +.model-editor-subtitle { margin: 4px 0 0; color: var(--muted-foreground); font-size: var(--fs-caption); font-weight: 400; } +.model-editor-section { display: grid; gap: 12px; min-width: 0; } +.model-editor-section + .model-editor-section { border-top: 1px solid var(--workspace-border); padding-top: 16px; } +.model-editor-section h2 { margin: 0; font-size: var(--fs-body); font-weight: 600; } +.model-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.model-editor-footer-actions { display: flex; gap: 8px; margin-left: auto; } +@media (max-width: 600px) { + .model-editor-modal .ant-modal-container { height: calc(100dvh - 24px); } + .model-editor-panel { padding: 16px; } + .model-editor-modal .ant-modal-header, .model-editor-modal .ant-modal-footer { padding: 16px; } +} diff --git a/web/src/components/model-editor-modal.tsx b/web/src/components/model-editor-modal.tsx new file mode 100644 index 000000000..40131ecd0 --- /dev/null +++ b/web/src/components/model-editor-modal.tsx @@ -0,0 +1,63 @@ +import { Modal, Tabs } from "antd"; +import type { ReactNode } from "react"; +import "./model-editor-modal.css"; + +export function ModelEditorModal({ + title, + subtitle, + open, + onClose, + footer, + activeKey, + onTabChange, + items, + busy = false, + admin = false, + children, +}: { + title: string; + subtitle?: string; + open: boolean; + onClose: () => void; + footer: ReactNode; + activeKey?: string; + onTabChange?: (key: string) => void; + items?: Array<{ key: string; label: string; children: ReactNode }>; + busy?: boolean; + admin?: boolean; + children?: ReactNode | ((tabs: ReactNode) => ReactNode); +}) { + const tabs = items?.length ? ( + ({ ...item, forceRender: true, children:
{item.children}
}))} + /> + ) : null; + const content = typeof children === "function" ? children(tabs) : children || tabs; + + return ( + + {title} + {subtitle &&

{subtitle}

} + + } + mask={{ closable: false }} + keyboard={!busy} + closable={!busy} + onCancel={onClose} + footer={footer} + styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0, minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }} + > + {content} +
+ ); +} diff --git a/web/src/components/model-protocol-browser.tsx b/web/src/components/model-protocol-browser.tsx new file mode 100644 index 000000000..f70df9312 --- /dev/null +++ b/web/src/components/model-protocol-browser.tsx @@ -0,0 +1,31 @@ +import { ChoiceBrowser } from "./choice-browser"; +import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols"; + +export function ModelProtocolBrowser({ capability, protocols, ...props }: { + capability?: ProtocolCapability; + protocols: ModelProtocolDefinition[]; + value?: string; + onChange?: (value: string) => void; + loading?: boolean; + error?: string; + disabled?: boolean; + id?: string; +}) { + const options = protocols + .filter((protocol) => (!capability || protocol.capability === capability) && protocol.enabled !== false) + .map((protocol) => ({ + value: protocol.value, + label: protocol.label, + subtitle: protocol.create, + vendor: protocol.vendor, + searchText: protocol.value, + details: [ + { label: "请求", value: {protocol.create} }, + { label: "请求体", value: protocol.contentType }, + { label: "响应", value: protocol.poll ? "异步轮询" : "同步响应" }, + { label: "来源", value: protocol.media }, + ], + })); + + return ; +} diff --git a/web/src/lib/canvas/canvas-live-viewport.ts b/web/src/lib/canvas/canvas-live-viewport.ts index 4f62404be..28f962a67 100644 --- a/web/src/lib/canvas/canvas-live-viewport.ts +++ b/web/src/lib/canvas/canvas-live-viewport.ts @@ -28,28 +28,15 @@ export type CanvasNodeSelectionPreview = { const nodeDragPreviewDomStates = new WeakMap(); const nodeSelectionPreviewDomStates = new WeakMap(); -const liveViewportElements = new WeakMap(); - -// 空间网格点模式的点半径(像素单位)。远距时使用更小半径,避免点阵糊成一团。 -export function canvasDotPx(scale: number): string { - return scale < 0.12 ? "0.6px" : "0.8px"; -} - -// 点阵在缩小时不再无限压缩到屏幕像素,避免密集视觉噪声。 -export function canvasDotGridPx(scale: number): number { - return Math.max(48 * scale, 32); -} +const liveViewportElements = new WeakMap(); export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) { if (!container) return; - const gridSize = 48 * viewport.k; - const dotGridSize = canvasDotGridPx(viewport.k); const committedScale = Number(container.style.getPropertyValue("--canvas-committed-scale")) || viewport.k; let elements = liveViewportElements.get(container); if (!elements) { elements = { worldLayer: container.querySelector("[data-canvas-world-layer]"), - gridLayer: container.querySelector("[data-canvas-grid-layer]"), }; liveViewportElements.set(container, elements); } @@ -63,17 +50,6 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo container.style.setProperty("--canvas-live-scale", String(viewport.k)); // 外置节点标题用同一帧逆倍率抵消世界层缩放,避免等待 React 提交后再校正尺寸。 container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05))); - const gridLayer = elements.gridLayer; - if (gridLayer) { - // 网格是唯一需要每帧更新背景偏移的元素,不把这些变量放在画布根节点上继承。 - gridLayer.style.setProperty("--canvas-grid-size", `${gridSize}px`); - gridLayer.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`); - gridLayer.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`); - gridLayer.style.setProperty("--canvas-dot-grid-size", `${dotGridSize}px`); - gridLayer.style.setProperty("--canvas-dot-grid-x", `${viewport.x % dotGridSize}px`); - gridLayer.style.setProperty("--canvas-dot-grid-y", `${viewport.y % dotGridSize}px`); - gridLayer.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k)); - } // 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。 container.dispatchEvent(new CustomEvent(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport })); if (notify) { diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx index 094d3502b..8e580f473 100644 --- a/web/src/pages/admin/admin-route-pages.tsx +++ b/web/src/pages/admin/admin-route-pages.tsx @@ -1,6 +1,6 @@ import { Button } from "antd"; import { Plus, Settings2, UserRoundCog } from "lucide-react"; -import { lazy, Suspense, useState } from "react"; +import { lazy, useState } from "react"; import { useAdminContext } from "./admin-context"; import { AdminPageFrame } from "./components/admin-shell"; @@ -14,17 +14,11 @@ const EmailSettingsPanel = lazy(() => import("./components/email-settings-panel" const FeatureAvailabilityPanel = lazy(() => import("./components/feature-availability-panel")); const StorageResourcesPanel = lazy(() => import("./components/storage-resources-panel")); -function PageFallback({ label }: { label: string }) { - return
正在读取{label}...
; -} - export function AnalyticsPage() { const { references } = useAdminContext(); return ( - }> - - + ); } @@ -53,9 +47,7 @@ export function AnnouncementsPage() { } > - }> - - + ); } @@ -78,9 +70,7 @@ export function CreditOperationsPage() { } > - }> - - + ); } @@ -88,9 +78,7 @@ export function CreditOperationsPage() { export function AccessSettingsPage() { return ( - }> - - + ); } @@ -98,9 +86,7 @@ export function AccessSettingsPage() { export function EmailSettingsPage() { return ( - }> - - + ); } @@ -108,9 +94,7 @@ export function EmailSettingsPage() { export function FeatureAvailabilityPage() { return ( - }> - - + ); } @@ -118,9 +102,7 @@ export function FeatureAvailabilityPage() { export function StorageResourcesPage() { return ( - }> - - + ); } diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 9bbee5336..45c6bf617 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -33,7 +33,7 @@ import { ToggleLeft, UsersRound, } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { Suspense, useEffect, useState, type ReactNode } from "react"; import { Link, NavLink, Outlet, useLocation } from "react-router"; import { AppChangelogButton } from "@/components/layout/app-changelog-modal"; @@ -164,7 +164,9 @@ export function AdminShell() {
- + 正在加载管理页面…}> + +
diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts new file mode 100644 index 000000000..8c3488cc0 --- /dev/null +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -0,0 +1,95 @@ +import type { ModelCapabilityChoice } from "@/components/model-protocol-picker"; +import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; +import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols"; +import type { ChannelModel } from "@/services/api/wallet"; +import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; + +export type ChannelModelFormValues = { + modelKey: string; + providerModelKey?: string; + displayName?: string; + icon?: string; + capability: ModelCapabilityChoice; + protocol?: string; + priceTiers: PriceTierFormValues[]; + enabled: boolean; + capabilityConfig?: ModelCapabilityConfig; +}; + +export type EditorSection = "identity" | "capabilities" | "pricing"; + +export function editorSectionForField(name: (string | number)[]): EditorSection { + return name[0] === "priceTiers" ? "pricing" : name[0] === "capabilityConfig" ? "capabilities" : "identity"; +} + +export function initialChannelModelValues(item: ChannelModel | null, protocols: ModelProtocolDefinition[]): ChannelModelFormValues { + const capability = item?.capability || "text"; + const protocol = item ? item.protocol : protocols.find((p) => p.capability === capability && p.enabled !== false)?.value; + const upstreamModel = item?.providerModelKey || item?.modelKey || ""; + return { + modelKey: item?.modelKey || "", + providerModelKey: upstreamModel, + displayName: item?.displayName || "", + icon: item?.icon || "", + capability, + protocol, + priceTiers: item ? (item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)]) : [defaultPriceTier()], + enabled: item?.enabled ?? true, + capabilityConfig: capability === "audio" ? undefined : normalizeModelCapabilityConfig(item?.capabilityConfig || defaultModelCapabilityConfig(protocol, upstreamModel)), + }; +} + +// Clear incompatible selectors, but never silently convert prices or billing units. +export function changeChannelModelCapability(values: ChannelModelFormValues, protocols: ModelProtocolDefinition[]): ChannelModelFormValues { + const capability = values.capability; + const protocol = protocols.find((p) => p.value === values.protocol && p.capability === capability && p.enabled !== false)?.value || protocols.find((p) => p.capability === capability && p.enabled !== false)?.value; + return { + ...values, + protocol, + capabilityConfig: capability === "audio" ? undefined : defaultModelCapabilityConfig(protocol, values.providerModelKey?.trim() || values.modelKey.trim()), + priceTiers: values.priceTiers.map((tier) => ({ + ...tier, + operation: "*", + quality: "*", + size: "*", + resolution: "*", + videoSeconds: 0, + imageCount: 0, + })), + }; +} + +export function validateChannelModelProtocol(capability: string, protocol: string | undefined, protocols: ModelProtocolDefinition[]) { + if (!protocols.some((p) => p.value === protocol && p.capability === capability && p.enabled !== false)) { + throw new Error("请选择当前能力下已启用的请求协议"); + } +} + +export function validateChannelModelPrices(values: Pick) { + const { capability, protocol, priceTiers } = values; + if (!priceTiers?.length) throw new Error("请至少配置一个价格档"); + if (priceTiers.filter((tier) => tier.matchMode === "default").length > 1) throw new Error("只能配置一个所有规格统一价格"); + const operations: Record = { + text: ["text_generation"], + image: ["text_to_image", "image_to_image"], + video: ["text_to_video", "image_to_video", "video_to_video"], + audio: [], + }; + priceTiers.forEach((tier, index) => { + const fail = (text: string) => { + throw new Error(`价格档 ${index + 1}:${text}`); + }; + if (!["fixed_request", "per_second", "token"].includes(tier.billingMode)) fail("请选择计费方式"); + if (tier.billingMode === "per_second" && capability !== "video") fail("按秒计费仅支持视频,请重新选择计费方式并核对价格"); + if (tier.billingMode === "token" && !modelProtocolSupportsTokenBilling(capability, protocol)) fail("当前协议不支持 Token 计费,请重新选择计费方式并核对价格"); + if (tier.matchMode === "advanced") { + if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配"); + const specific = (value: string | undefined) => Boolean(value && value !== "*"); + if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0)))) + fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格"); + } + const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice]; + if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值"); + if (tier.billingMode === "token" && capability === "video" && tier.outputTokenPrice < 0.000001) fail("视频 Token 价格必须至少为 0.000001"); + }); +} diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx new file mode 100644 index 000000000..650e5059b --- /dev/null +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -0,0 +1,421 @@ +import { useRef, useState } from "react"; +import { Alert, App, Button, Form, Input, Modal, Segmented, Switch, Tabs } from "antd"; +import { FlaskConical, Plus } from "lucide-react"; +import { ModelIconPicker } from "@/components/model-logo"; +import { ModelIcon } from "@/components/model-picker"; +import { ModelProtocolBrowser } from "@/components/model-protocol-browser"; +import { ModelCapabilityEditor } from "@/components/model-capability-editor"; +import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; +import type { ModelProtocolDefinition } from "@/lib/model-protocols"; +import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet"; +import type { ModelChannel } from "@/stores/use-config-store"; +import { defaultPriceTier, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form"; +import { PriceTierFields } from "./channel-model-price-tier-fields"; +import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form"; + +export function ChannelModelEditor({ + channel, + editing, + protocols, + protocolLoading, + protocolError, + onRetryProtocols, + onClose, + onSaved, +}: { + channel: ModelChannel; + editing: ChannelModel | null; + protocols: ModelProtocolDefinition[]; + protocolLoading: boolean; + protocolError: string; + onRetryProtocols: () => void; + onClose: () => void; + onSaved: () => Promise; +}) { + const { message, modal } = App.useApp(); + const [form] = Form.useForm(); + const [initialValues] = useState(() => initialChannelModelValues(editing, protocols)); + const [activeSection, setActiveSection] = useState("identity"); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [configurationChanged, setConfigurationChanged] = useState(false); + const busyRef = useRef(false); + const dirtyRef = useRef(false); + const modelCapability = Form.useWatch("capability", form); + const modelProtocol = Form.useWatch("protocol", form); + const modelKey = Form.useWatch("modelKey", form) || ""; + const providerModelKey = Form.useWatch("providerModelKey", form) || ""; + const capabilityConfig = Form.useWatch("capabilityConfig", form); + const modelEnabled = Form.useWatch("enabled", form) !== false; + const priceTiers = Form.useWatch("priceTiers", form) || []; + const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default"); + const busy = saving || testing; + + const requestClose = () => { + if (busyRef.current) return; + if (!dirtyRef.current) { + onClose(); + return; + } + modal.confirm({ title: "放弃未保存的修改?", content: "当前修改尚未保存,关闭后将丢弃。", okText: "放弃修改", cancelText: "继续编辑", onOk: onClose }); + }; + + const handleFormValuesChange = (changed: Partial) => { + dirtyRef.current = true; + if (changed.capability) { + form.setFieldsValue(changeChannelModelCapability(form.getFieldsValue(true), protocols)); + setConfigurationChanged(true); + } else if (changed.protocol) { + form.setFieldValue("capabilityConfig", modelCapability === "audio" ? undefined : defaultModelCapabilityConfig(changed.protocol, providerModelKey.trim() || modelKey.trim())); + setConfigurationChanged(true); + } + }; + + const validateEditor = async (names?: string[]): Promise => { + busyRef.current = true; + try { + await form.validateFields(names); + return form.getFieldsValue(true); + } catch (error) { + const fields = (error as { errorFields?: { name: (string | number)[] }[] }).errorFields; + if (fields?.length) { + setActiveSection(editorSectionForField(fields[0].name)); + requestAnimationFrame(() => form.scrollToField(fields[0].name, { block: "center", focus: true })); + message.warning("请检查标记的配置项后重试"); + } else message.error(error instanceof Error ? error.message : "表单校验失败"); + return undefined; + } finally { + busyRef.current = false; + } + }; + + const save = async () => { + if (busyRef.current) return; + const values = await validateEditor(); + if (!values) return; + const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim(); + const capabilityConfig = + values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined; + busyRef.current = true; + setSaving(true); + try { + const payload = { + modelKey: values.modelKey.trim(), + providerModelKey: upstreamModel, + displayName: values.displayName?.trim() || values.modelKey.trim(), + icon: values.icon?.trim() || "", + capability: values.capability, + protocol: values.protocol, + priceTiers: values.priceTiers.map((tier) => ({ + selector: skuSelectorFromForm(values.capability, tier), + resolution: priceTierResolutionFromForm(values.capability, tier), + videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier), + providerModelKey: tier.providerModelKey?.trim() || upstreamModel, + billingMode: tier.billingMode, + unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000), + inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000), + outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000), + cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000), + priceConfigured: tier.priceConfigured !== false, + enabled: tier.enabled !== false, + })), + enabled: values.enabled !== false, + capabilityConfig, + }; + if (editing) await updateAdminChannelModel(channel.id, editing.id, payload); + else await createAdminChannelModel(channel.id, payload); + try { + await onSaved(); + } catch { + message.warning("模型已保存,但列表刷新失败,请手动刷新"); + } + onClose(); + message.success(editing ? "模型配置已更新" : "模型已添加"); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存模型失败"); + } finally { + busyRef.current = false; + setSaving(false); + } + }; + + const testModel = async () => { + if (busyRef.current) return; + const values = await validateEditor(["modelKey", "providerModelKey", "capability", "protocol", "capabilityConfig"]); + if (!values) return; + const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim(); + const capabilityConfig = + values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined; + busyRef.current = true; + setTesting(true); + try { + const result = await testAdminChannelModel(channel.id, { + modelKey: values.modelKey.trim(), + providerModelKey: upstreamModel, + capability: values.capability, + protocol: values.protocol, + capabilityConfig, + }); + message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`); + } catch (error) { + message.error(error instanceof Error ? error.message : "模型测试失败"); + } finally { + busyRef.current = false; + setTesting(false); + } + }; + + return ( + + {editing ? "编辑模型" : "新增模型"} +

+ {channel.name} · {editing?.displayName || editing?.modelKey || "配置调用方式与用户积分价格"} +

+ + } + width={1120} + centered + rootClassName="admin-modal-root admin-model-editor-modal" + mask={{ closable: false }} + keyboard={!busy} + closable={!busy} + onCancel={requestClose} + styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0, minHeight: 0, flex: 1 }, header: { margin: 0 }, footer: { margin: 0 } }} + footer={ +
+
+ { + dirtyRef.current = true; + form.setFieldValue("enabled", checked); + }} + /> +
+ {modelEnabled ? "模型启用" : "模型停用"} + 保存后生效 +
+
+
+ + + +
+
+ } + > +
+ + + + + + + setActiveSection(key as EditorSection)} + animated={false} + items={[ + { + key: "identity", + label: "基本信息", + forceRender: true, + children: ( +
+
+ +
+ + + + + } + placeholder="例如:seedance-2-5" + /> + + + + + + + + + + +
+
+
+ +
+ + + + validateChannelModelProtocol(form.getFieldValue("capability"), value, protocols) }]} + > + + + {protocolError && ( + + 重试 + + } + /> + )} +
+
+
+ ), + }, + { + key: "capabilities", + label: "能力与参数", + forceRender: true, + children: ( +
+ {configurationChanged && }{" "} + {modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ( +
+ +
+ { + dirtyRef.current = true; + form.setFieldValue("capabilityConfig", next); + }} + /> +
+
+ ) : null} + {modelCapability === "image" || modelCapability === "video" ? ( +
+ +
+ { + dirtyRef.current = true; + form.setFieldValue("capabilityConfig", next); + }} + /> +
+
+ ) : null} + {modelCapability === "audio" && } +
+ ), + }, + { + key: "pricing", + label: "积分定价", + forceRender: true, + children: ( +
+ {configurationChanged && }{" "} +
+ +
+ { + validateChannelModelPrices({ capability: form.getFieldValue("capability"), protocol: form.getFieldValue("protocol"), priceTiers: value }); + }, + }, + ]} + > + {(fields, { add, remove }, { errors }) => ( +
+ {fields.map((field, index) => ( + { + dirtyRef.current = true; + }} + onRemove={() => remove(field.name)} + /> + ))} + + +
+ )} +
+
+
+
+ ), + }, + ]} + /> + +
+ ); +} + +function SectionHeading({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ); +} +function CapabilityConfigField(_: { value?: ModelCapabilityConfig; onChange?: (value: ModelCapabilityConfig) => void }) { + return null; +} +function EnabledConfigField(_: { value?: boolean; onChange?: (value: boolean) => void }) { + return null; +} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index d3fa8a25f..9dcc3cd8f 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -1,36 +1,18 @@ import { useEffect, useState } from "react"; -import { App, Button, Checkbox, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd"; +import { App, Button, Checkbox, Input, Modal, Popconfirm, Select, Space } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import { Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { PaginationBar } from "@/components/layout/workspace-page"; -import { ModelIconPicker } from "@/components/model-logo"; import { ModelIcon } from "@/components/model-picker"; -import { ModelCapabilityEditor } from "@/components/model-capability-editor"; -import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker"; -import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities"; -import { modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols"; +import { modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols"; import { fetchPluginProviderCatalog } from "@/services/api/plugin-catalog"; -import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; +import { deleteAdminChannelModel, fetchAdminChannelModels, importAdminChannelModels, listAdminChannelModels, type ChannelModel, type ChannelModelPriceTier } from "@/services/api/wallet"; import type { ModelChannel } from "@/stores/use-config-store"; -import { defaultPriceTier, legacyPriceTierToForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm, type PriceTierFormValues } from "./channel-model-price-tier-form"; +import { ChannelModelEditor } from "./channel-model-editor"; import { AdminPageFrame } from "./admin-shell"; import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui"; -type EditableCapability = ModelCapabilityChoice; - -type FormValues = { - modelKey: string; - providerModelKey?: string; - displayName?: string; - icon?: string; - capability: EditableCapability; - protocol?: ModelProtocol; - priceTiers: PriceTierFormValues[]; - enabled: boolean; - capabilityConfig?: ModelCapabilityConfig; -}; - export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise }) { const { message } = App.useApp(); const [items, setItems] = useState([]); @@ -41,8 +23,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const [fetchPreviewModels, setFetchPreviewModels] = useState([]); const [selectedFetchModels, setSelectedFetchModels] = useState([]); const [importing, setImporting] = useState(false); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); const [editorOpen, setEditorOpen] = useState(false); const [keyword, setKeyword] = useState(""); const [capability, setCapability] = useState("all"); @@ -50,16 +30,20 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [availableProtocols, setAvailableProtocols] = useState([]); - const [form] = Form.useForm(); - const modelCapability = Form.useWatch("capability", form); - const modelProtocol = Form.useWatch("protocol", form); - const modelKey = Form.useWatch("modelKey", form) || ""; - const providerModelKey = Form.useWatch("providerModelKey", form) || ""; - const capabilityConfig = Form.useWatch("capabilityConfig", form); - const modelEnabled = Form.useWatch("enabled", form) !== false; - const priceTiers = Form.useWatch("priceTiers", form) || []; - const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default"); - + const [protocolLoading, setProtocolLoading] = useState(true); + const [protocolError, setProtocolError] = useState(""); + const loadProtocols = async () => { + setProtocolLoading(true); + setProtocolError(""); + try { + setAvailableProtocols(await fetchPluginProviderCatalog("admin.system-channel")); + } catch (error) { + setAvailableProtocols([]); + setProtocolError(error instanceof Error ? error.message : "无法读取协议目录"); + } finally { + setProtocolLoading(false); + } + }; const reload = async () => { if (!channel) return; setLoading(true); @@ -74,9 +58,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: useEffect(() => { void reload(); - void fetchPluginProviderCatalog("admin.system-channel") - .then(setAvailableProtocols) - .catch(() => setAvailableProtocols([])); + void loadProtocols(); setEditing(null); setEditorOpen(false); resetFetchPreview(); @@ -139,105 +121,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const startCreate = () => { setEditing(null); - form.setFieldsValue({ - modelKey: "", - providerModelKey: "", - displayName: "", - icon: "", - capability: "text", - protocol: availableProtocols.find((item) => item.capability === "text")?.value, - priceTiers: [defaultPriceTier()], - enabled: true, - capabilityConfig: defaultModelCapabilityConfig(availableProtocols.find((item) => item.capability === "text")?.value, ""), - }); setEditorOpen(true); }; - const startEdit = (item: ChannelModel) => { setEditing(item); - form.setFieldsValue({ - modelKey: item.modelKey, - providerModelKey: item.providerModelKey || item.modelKey, - displayName: item.displayName, - icon: item.icon, - capability: item.capability || undefined, - protocol: item.protocol, - priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)], - enabled: item.enabled, - capabilityConfig: - item.capability === "text" || item.capability === "image" || item.capability === "video" - ? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey)) - : undefined, - }); setEditorOpen(true); }; - const save = async () => { - const values = await form.validateFields(); - const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim(); - const capabilityConfig = - values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined; - setSaving(true); - try { - const payload = { - modelKey: values.modelKey.trim(), - providerModelKey: upstreamModel, - displayName: values.displayName?.trim() || values.modelKey.trim(), - icon: values.icon?.trim() || "", - capability: values.capability, - protocol: values.protocol, - priceTiers: values.priceTiers.map((tier) => ({ - selector: skuSelectorFromForm(values.capability, tier), - resolution: priceTierResolutionFromForm(values.capability, tier), - videoSeconds: priceTierVideoSecondsFromForm(values.capability, tier), - providerModelKey: tier.providerModelKey?.trim() || upstreamModel, - billingMode: tier.billingMode, - unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000), - inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000), - outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000), - cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000), - priceConfigured: tier.priceConfigured !== false, - enabled: tier.enabled !== false, - })), - enabled: values.enabled !== false, - capabilityConfig, - }; - if (editing) await updateAdminChannelModel(channel.id, editing.id, payload); - else await createAdminChannelModel(channel.id, payload); - await reload(); - await onChanged(); - setEditorOpen(false); - setEditing(null); - message.success(editing ? "模型配置已更新" : "模型已添加"); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存模型失败"); - } finally { - setSaving(false); - } - }; - - const testModel = async () => { - const values = await form.validateFields(["modelKey", "providerModelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]); - const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim(); - const capabilityConfig = - values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined; - setTesting(true); - try { - const result = await testAdminChannelModel(channel.id, { - modelKey: values.modelKey.trim(), - providerModelKey: upstreamModel, - capability: values.capability, - protocol: values.protocol, - capabilityConfig, - }); - message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`); - } catch (error) { - message.error(error instanceof Error ? error.message : "模型测试失败"); - } finally { - setTesting(false); - } - }; - const remove = async (item: ChannelModel) => { try { await deleteAdminChannelModel(channel.id, item.id); @@ -249,30 +139,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: } }; - const handleFormValuesChange = (changed: Partial) => { - if (changed.protocol && (modelCapability === "image" || modelCapability === "video")) { - form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol, form.getFieldValue("modelKey"))); - } - if (!changed.capability) return; - const current = form.getFieldValue("protocol") as ModelProtocol | undefined; - if (modelProtocolCapability(current, availableProtocols) !== changed.capability) { - const nextProtocol = availableProtocols.find((item) => item.capability === changed.capability)?.value; - form.setFieldValue("protocol", nextProtocol); - form.setFieldValue("capabilityConfig", changed.capability === "text" || changed.capability === "image" || changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol, form.getFieldValue("modelKey")) : undefined); - } - const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({ - ...tier, - operation: tier.operation || "*", - quality: changed.capability === "image" ? tier.quality || "*" : "*", - size: changed.capability === "image" ? tier.size || "*" : "*", - resolution: changed.capability === "video" ? tier.resolution || "*" : "*", - videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0, - imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0, - billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode, - })); - form.setFieldValue("priceTiers", nextTiers); - }; - const columns: ColumnsType = [ { title: "模型", @@ -493,12 +359,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:

上游共返回 {fetchPreviewModels.length} 个模型。默认已全选,请取消本次不需要拉入的模型;已存在的模型不会重复导入。

- setSelectedFetchModels(values as string[])} - /> + setSelectedFetchModels(values as string[])} />
{selectedNewFetchModels.length > 0 ? `将导入 ${selectedNewFetchModels.length} 个新模型` : "当前勾选的模型均已存在"} @@ -506,355 +367,29 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
- !saving && setEditorOpen(false)} - rootClassName="admin-drawer admin-model-editor-drawer" - footer={ -
- -
-
- -
- {modelEnabled ? "模型启用" : "模型停用"} - 保存后生效 -
- form.setFieldValue("enabled", checked)} /> -
- - -
-
- } - extra={ - editing ? ( - - ) : null - } - > -
- - - - - - -
- -
- - - - - } - placeholder="例如:seedance-2-5" - /> - - - - - - - - - - -
-
- -
- -
- - - -
-
- - {availableProtocols.length ? ( -
- -
- - - -
-
- ) : null} - - {modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ( -
- -
- form.setFieldValue("capabilityConfig", next)} - /> -
-
- ) : null} - - {modelCapability === "image" || modelCapability === "video" ? ( -
- -
- form.setFieldValue("capabilityConfig", next)} - /> -
-
- ) : null} - -
- -
- { - if (!value?.length) throw new Error("请至少配置一个价格档"); - if (value.filter((tier: PriceTierFormValues) => tier.matchMode === "default").length > 1) throw new Error("只能配置一个所有规格统一价格"); - }, - }, - ]} - > - {(fields, { add, remove }, { errors }) => ( -
- {fields.map((field, index) => ( - remove(field.name)} - /> - ))} - - -
- )} -
-
-
-
-
+ {editorOpen && ( + void loadProtocols()} + onClose={() => setEditorOpen(false)} + onSaved={async () => { + await reload(); + await onChanged(); + }} + /> + )} ); } -function SectionHeading({ title, description }: { title: string; description: string }) { - return ( -
-

{title}

-

{description}

-
- ); -} - -function CapabilityConfigField(_: { value?: ModelCapabilityConfig; onChange?: (value: ModelCapabilityConfig) => void }) { - return null; -} - -function EnabledConfigField(_: { value?: boolean; onChange?: (value: boolean) => void }) { - return null; -} - function capabilityLabel(value: ChannelModel["capability"]) { return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value]; } -function PriceTierFields({ - index, - ordinal, - form, - capability, - protocol, - capabilityConfig, - onRemove, -}: { - index: number; - ordinal: number; - form: FormInstance; - capability: EditableCapability | undefined; - protocol: ModelProtocol | undefined; - capabilityConfig?: ModelCapabilityConfig; - onRemove: () => void; -}) { - const billingMode = Form.useWatch(["priceTiers", index, "billingMode"], form) || "fixed_request"; - const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default"; - const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false; - const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false; - const video = capabilityConfig?.video; - const resolutionOptions = video?.resolutions || []; - const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : []; - const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol)); - const isVideo = capability === "video"; - const isImage = capability === "image"; - return ( -
-
-
-
{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}
-
{matchMode === "default" ? "匹配所有生成请求,保存后即可供用户使用。" : "精确条件优先于默认价格。"}
-
- -
-
-
-
- 01 价格适用范围 -
- - - - {matchMode === "default" ? ( -
用户选择任意生成方式、质量和尺寸时,都使用这档价格。新增规格价格后,精确规则优先,默认价格负责兜底。
- ) : ( -
- - ({ label: value.toUpperCase(), value }))]} /> - - ) : null} - {isVideo ? ( - - {durationOptions.length ? - - ) : null} - {isImage ? ( - - - - ) : null} - - - -
- )} -
-
-
- 02 计费与状态 -
-
- - - - {billingMode === "token" ? ( - isVideo ? ( - - - - ) : ( -
- - - - - - - - - -
- ) - ) : ( - - - - )} -
- - -
-
- 可供用户使用 - 开启后参与用户请求匹配;关闭后仅保留配置,不参与计费匹配 -
- { - form.setFieldValue(["priceTiers", index, "priceConfigured"], checked); - form.setFieldValue(["priceTiers", index, "enabled"], checked); - }} - /> -
-
-
-
-
-
- ); -} - function billingSummary(item: ChannelModel) { const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || []; if (!tiers.length) return ; @@ -883,14 +418,6 @@ function priceTierLabel(tier: ChannelModelPriceTier) { return `${spec} · ${formatCredits(tier.unitPriceMicrocredits)} 积分 / ${tier.billingMode === "per_second" ? "秒" : "次"}`; } -function operationOptions(capability: EditableCapability | undefined) { - const options = [{ label: "任意生成方式", value: "*" }]; - if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }]; - if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }]; - if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }]; - return options; -} - function operationLabel(operation: string) { return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record)[operation] || operation; } @@ -900,5 +427,8 @@ function formatCredits(value: number) { } function normalizeFetchModelKey(value: string) { - return value.trim().replace(/^models\//, "").toLowerCase(); + return value + .trim() + .replace(/^models\//, "") + .toLowerCase(); } diff --git a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx new file mode 100644 index 000000000..65b826d64 --- /dev/null +++ b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx @@ -0,0 +1,169 @@ +import { Button, Form, Input, InputNumber, Segmented, Select, Switch, type FormInstance } from "antd"; +import { Trash2 } from "lucide-react"; +import type { ModelCapabilityConfig } from "@/lib/model-capabilities"; +import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols"; +import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker"; +import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form"; + +export function PriceTierFields({ + index, + ordinal, + form, + capability, + protocol, + capabilityConfig, + onDirty, + onRemove, +}: { + index: number; + ordinal: number; + form: FormInstance; + capability: EditableCapability | undefined; + protocol: ModelProtocol | undefined; + capabilityConfig?: ModelCapabilityConfig; + onDirty: () => void; + onRemove: () => void; +}) { + const billingMode = Form.useWatch(["priceTiers", index, "billingMode"], form) || "fixed_request"; + const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default"; + const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false; + const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false; + const video = capabilityConfig?.video; + const resolutionOptions = video?.resolutions || []; + const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : []; + const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol)); + const isVideo = capability === "video"; + const isImage = capability === "image"; + return ( +
+
+
+
{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}
+
+
+ + +
+ 可供用户使用 + { + onDirty(); + form.setFieldValue(["priceTiers", index, "priceConfigured"], checked); + form.setFieldValue(["priceTiers", index, "enabled"], checked); + }} + /> +
+
+ +
+
+
+ + + +
+
+
+ + + + {billingMode === "token" ? ( + isVideo ? ( + + + + ) : ( +
+ + + + + + + + + +
+ ) + ) : ( + + + + )} +
+
+ {matchMode !== "default" && ( +
+ + ({ label: value.toUpperCase(), value }))]} /> + + ) : null} + {isVideo ? ( + + {durationOptions.length ? + + ) : null} + {isImage ? ( + + + + ) : null} + + + +
+ )} +
+
+ ); +} + +function operationOptions(capability: EditableCapability | undefined) { + const options = [{ label: "任意生成方式", value: "*" }]; + if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }]; + if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }]; + if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }]; + return options; +} diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index 421b2c944..6697395ec 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -1,12 +1,13 @@ -import { Alert, App, Button, Drawer, Form, Input, InputNumber, Modal, Select, Switch, Table, Tag } from "antd"; +import { Alert, App, Button, Form, Input, InputNumber, Modal, Segmented, Select, Switch, Table, Tag } from "antd"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Archive, FlaskConical, GitBranch, Layers3, Pencil, Plus, Search } from "lucide-react"; -import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { PaginationBar } from "@/components/layout/workspace-page"; import { ModelIconPicker, ModelLogo } from "@/components/model-logo"; -import { CapabilityCardPicker } from "@/components/model-protocol-picker"; +import { ChoiceBrowser } from "@/components/choice-browser"; +import { ModelEditorModal } from "@/components/model-editor-modal"; import { AdminPageFrame } from "@/pages/admin/components/admin-shell"; import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui"; import { listAdminChannels } from "@/services/api/auth"; @@ -59,7 +60,14 @@ type LogicalModelFormValues = { routes: RouteRuleRow[]; }; export default function LogicalModelsPage() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); + const [editorTab, setEditorTab] = useState("identity"); + const dirtyRef = useRef(false); + const closeEditor = () => { + if (saving) return; + if (dirtyRef.current) modal.confirm({ title: "放弃未保存的修改?", okText: "放弃修改", cancelText: "继续编辑", onOk: () => setEditingModel(undefined) }); + else setEditingModel(undefined); + }; const [keyword, setKeyword] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); @@ -77,6 +85,7 @@ export default function LogicalModelsPage() { const [simulationResult, setSimulationResult] = useState(); const [simulating, setSimulating] = useState(false); const [modelForm] = Form.useForm(); + const modelEnabled = Form.useWatch("enabled", modelForm) !== false; const modelCapability = Form.useWatch("capability", modelForm) || "image"; const modelRoutes = Form.useWatch("routes", modelForm) || []; const modelCapabilitySpec = Form.useWatch("capabilitySpec", modelForm); @@ -118,6 +127,8 @@ export default function LogicalModelsPage() { const openModel = (item?: AdminLogicalModel) => { const capability = item?.capability || "image"; + dirtyRef.current = false; + setEditorTab("identity"); modelForm.resetFields(); modelForm.setFieldsValue( item @@ -145,13 +156,26 @@ export default function LogicalModelsPage() { }; const saveModel = async () => { - const values = await modelForm.validateFields(); + let values: LogicalModelFormValues; + try { + values = await modelForm.validateFields(); + } catch (error) { + const fields = (error as { errorFields?: { name: (string | number)[] }[] }).errorFields; + if (fields?.length) { + const name = fields[0].name; + setEditorTab(name[0] === "routes" ? "routes" : ["capabilitySpec", "defaultOptions"].includes(String(name[0])) ? "capabilities" : "identity"); + requestAnimationFrame(() => modelForm.scrollToField(name, { block: "center", focus: true })); + } + return; + } if (values.enabled && !values.routes.length) { + setEditorTab("routes"); message.error("请至少添加一条供应线路"); return; } const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec); if (values.enabled && sourceError) { + setEditorTab("capabilities"); message.error(sourceError); return; } @@ -330,96 +354,116 @@ export default function LogicalModelsPage() { } /> - !saving && setEditingModel(undefined)} - rootClassName="admin-drawer" + busy={saving} + title={editingModel ? "编辑前台模型" : "新增前台模型"} + subtitle={editingModel?.name || "配置前台展示、供应线路与能力范围"} + onClose={closeEditor} + activeKey={editorTab} + onTabChange={setEditorTab} footer={ -
- - +
+ +
+ + +
} + items={[ + { + key: "identity", + label: "基本信息", + children: <> + {editingModel?.configurationError || editingModel?.availabilityError ? ( + + ) : null} + } title="前台展示"> +
+ + + + + + + +
+ + + + + + + + + +
+ , + }, + { + key: "routes", + label: "供应线路", + children: <> + } title="供应线路"> + + + + , + }, + { + key: "capabilities", + label: "能力与参数", + children: <> + + + + + + + + + + + , + }, + ]} > -
) => { - const capability = changedValues.capability; - if (!capability) return; - modelForm.setFieldsValue({ - routes: [], - capabilitySpec: emptyCapabilitySpec(capability), - defaultOptions: {}, - pricePolicy: "channel", - billingMode: "fixed_request", - }); - }} - > - {editingModel?.configurationError || editingModel?.availabilityError ? ( - - ) : null} - } title="前台展示"> -
- - - - - - - - - -
- - - - - - -
- - - - - - -
-
- } title="供应线路"> - - - - - - - - - - - - - - - - - + {(tabs) => ( +
) => { + dirtyRef.current = true; + if (!changedValues.capability) return; + modelForm.setFieldsValue({ routes: [], capabilitySpec: emptyCapabilitySpec(changedValues.capability), defaultOptions: {}, pricePolicy: "channel", billingMode: "fixed_request" }); + }} + > + + {tabs} +
+ )} + +
{icon ? {icon} : null}
@@ -512,6 +556,14 @@ function RouteFields({ value: item.id, label: `${channelNames[item.channelId]} / ${item.displayName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`, disabled: Boolean(unavailableReason), + subtitle: item.providerModelKey || item.modelKey, + details: [ + { label: "渠道", value: channelNames[item.channelId] }, + { label: "模型", value: item.displayName || item.modelKey }, + { label: "上游 ID", value: item.providerModelKey || item.modelKey }, + { label: "协议", value: item.protocol || "未配置" }, + { label: "状态", value: unavailableReason || "可用" }, + ], }; }); const availableChannelModelCount = selectOptions.filter((item) => !item.disabled).length; @@ -530,7 +582,7 @@ function RouteFields({
{fields.length ? ( -
+
{fields.map((field) => { const routes = (form.getFieldValue("routes") || []) as RouteRuleRow[]; const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.channelModelId)).filter(Boolean)); @@ -548,11 +600,10 @@ function RouteFields({
- updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /> - - updateChannel(channel.id, { models: uniqueModels(models) })} /> -
updateChannel(channel.id, { headers })} />
-
- updateChannel(channel.id, { modelCosts })} /> -
+ {editing && ( + + 更改实时保存到本地渠道配置 +
+ + +
+
} + > +
+
+
+

连接信息

+

用于拉取模型目录并向当前渠道发起请求。

+
+
+ updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /> + block value={channelConnectionMode(channel)} options={[{ label: "OpenAI", value: "openai" }, { label: "Gemini", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /> + + updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /> + updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /> +
updateChannel(channel.id, { headers })} />
+
+
+
+
+

模型与能力

+

维护渠道模型,并在单个模型中配置调用协议、能力和定价。

+
+