chore(web): 前端质量检查 - 规范模型编辑格式并消除回归测试缩进依赖

This commit is contained in:
ddCat
2026-09-06 07:42:02 +08:00
parent d920649387
commit df94c3bdf1
11 changed files with 305 additions and 136 deletions

View File

@@ -11,6 +11,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- 默认前端测试命令已接入模型编辑表单、价格档、前台模型与画布网格回归;最终 `bun run test` 的 39 + 4 + 1120 + 1 项测试全部通过,前端生产构建通过,仍有既有包体积与混合静动态导入警告。
- 隔离组件页使用明确的测试协议数据完成浅色桌面、390px 暗色窄屏、协议搜索选择与详情联动、长内容滚动和固定底栏验收;后台编辑器跨页签必填校验能定位基本信息,取消修改会确认且可继续编辑。校验期间无 XHR/fetch 请求及控制台错误,不代表真实路由或后端保存已验收。
- 本轮相对 rc2 无后端源码变更,未重跑后端全量;rc2 记录的既有后端测试失败仍不视为解决。
- 标签推送后的远程质量检查中,后端全量与支付插件检查通过,前端被 9 个文件的格式检查阻断。主分支追加格式修复,并将协议浏览器静态回归的定位条件改为忽略换行缩进;本地 1164 项前端测试复跑通过。已推送的 rc3 标签不改写,该补充提交不改变应用业务逻辑。
- [ ] 发布产物验收:前后端 Linux amd64/arm64 镜像、双架构更新器校验和、部署 Compose 附件以及镜像内版本和提交信息。
- [ ] 部署后验收:实际模型新增/编辑保存、真实协议与计费组合、后台路由切换、画布缩放以及登录和任务生成。本次不部署、不写入真实模型配置、不执行收费生成。

View File

@@ -7,12 +7,18 @@
}
.choice-browser-panels {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, .65fr);
grid-template-columns: minmax(0, 1.35fr) minmax(0, 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-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;
@@ -23,26 +29,105 @@
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; }
.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: 0.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;
}
}

View File

@@ -42,24 +42,12 @@ export function ChoiceBrowser({
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 visible = options.filter((option) => [option.label, option.subtitle, option.vendor, option.searchText].join(" ").toLowerCase().includes(normalizedQuery));
const unavailable = disabled || loading || Boolean(error);
return (
<div className="choice-browser">
<Input
id={id}
{...aria}
allowClear
aria-label={`搜索${label}`}
disabled={unavailable}
prefix={<Search className="size-3.5" aria-hidden="true" />}
placeholder={placeholder}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<Input id={id} {...aria} allowClear aria-label={`搜索${label}`} disabled={unavailable} prefix={<Search className="size-3.5" aria-hidden="true" />} placeholder={placeholder} value={query} onChange={(event) => setQuery(event.target.value)} />
{loading || error || !options.length ? (
<div className="choice-browser-state" role={error ? "alert" : "status"}>
{loading ? "正在读取可选项…" : error || "暂无可用选项"}
@@ -76,14 +64,7 @@ export function ChoiceBrowser({
<span className="choice-browser-vendor" title={option.vendor}>
{option.vendor}
</span>
<input
type="radio"
name={name}
value={option.value}
checked={option.value === value}
disabled={unavailable || option.disabled}
onChange={() => onChange?.(option.value)}
/>
<input type="radio" name={name} value={option.value} checked={option.value === value} disabled={unavailable || option.disabled} onChange={() => onChange?.(option.value)} />
</label>
))}
{!visible.length && (

View File

@@ -1,23 +1,103 @@
.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; }
.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;
}
}

View File

@@ -27,14 +27,7 @@ export function ModelEditorModal({
admin?: boolean;
children?: ReactNode | ((tabs: ReactNode) => ReactNode);
}) {
const tabs = items?.length ? (
<Tabs
activeKey={activeKey}
onChange={onTabChange}
animated={false}
items={items.map((item) => ({ ...item, forceRender: true, children: <div className="model-editor-panel">{item.children}</div> }))}
/>
) : null;
const tabs = items?.length ? <Tabs activeKey={activeKey} onChange={onTabChange} animated={false} items={items.map((item) => ({ ...item, forceRender: true, children: <div className="model-editor-panel">{item.children}</div> }))} /> : null;
const content = typeof children === "function" ? children(tabs) : children || tabs;
return (

View File

@@ -1,7 +1,11 @@
import { ChoiceBrowser } from "./choice-browser";
import type { ModelProtocolDefinition, ProtocolCapability } from "@/lib/model-protocols";
export function ModelProtocolBrowser({ capability, protocols, ...props }: {
export function ModelProtocolBrowser({
capability,
protocols,
...props
}: {
capability?: ProtocolCapability;
protocols: ModelProtocolDefinition[];
value?: string;

View File

@@ -164,7 +164,13 @@ export function AdminShell() {
</Tooltip>
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
<MobileAdminNavigation />
<Suspense fallback={<div className="p-8 text-sm text-foreground/60" role="status">正在加载管理页面…</div>}>
<Suspense
fallback={
<div className="p-8 text-sm text-foreground/60" role="status">
正在加载管理页面…
</div>
}
>
<Outlet />
</Suspense>
</section>

View File

@@ -276,12 +276,7 @@ export function ChannelModelEditor({
]}
/>
</Form.Item>
<Form.Item
className="admin-model-protocol-field"
name="protocol"
label="调用协议"
rules={[{ validator: async (_, value) => validateChannelModelProtocol(form.getFieldValue("capability"), value, protocols) }]}
>
<Form.Item className="admin-model-protocol-field" name="protocol" label="调用协议" rules={[{ validator: async (_, value) => validateChannelModelProtocol(form.getFieldValue("capability"), value, protocols) }]}>
<ModelProtocolBrowser disabled={busy} capability={modelCapability} protocols={protocols} loading={protocolLoading} error={protocolError} />
</Form.Item>
{protocolError && (

View File

@@ -378,8 +378,12 @@ export default function LogicalModelsPage() {
模型启用 <span className="text-xs text-foreground/50">保存后生效</span>
</label>
<div className="model-editor-footer-actions">
<Button disabled={saving} onClick={closeEditor}>取消</Button>
<Button type="primary" loading={saving} onClick={() => void saveModel()}>{editingModel ? "保存修改" : "添加模型"}</Button>
<Button disabled={saving} onClick={closeEditor}>
取消
</Button>
<Button type="primary" loading={saving} onClick={() => void saveModel()}>
{editingModel ? "保存修改" : "添加模型"}
</Button>
</div>
</div>
}
@@ -387,63 +391,81 @@ export default function LogicalModelsPage() {
{
key: "identity",
label: "基本信息",
children: <>
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
<EditorSection icon={<Layers3 className="size-4" />} title="前台展示">
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="name" label="显示名称" rules={[{ required: true, message: "请填写显示名称" }]}>
<Input placeholder="例如:Seedance 视频" />
children: (
<>
{editingModel?.configurationError || editingModel?.availabilityError ? (
<Alert
className="mb-4"
type="warning"
showIcon
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
description={editingModel.configurationError || editingModel.availabilityError}
/>
) : null}
<EditorSection icon={<Layers3 className="size-4" />} title="前台展示">
<div className="grid gap-3 sm:grid-cols-2">
<Form.Item name="name" label="显示名称" rules={[{ required: true, message: "请填写显示名称" }]}>
<Input placeholder="例如:Seedance 视频" />
</Form.Item>
<Form.Item name="code" label="模型代码" rules={[{ required: true, message: "请填写模型代码" }]}>
<Input placeholder="例如:seedance-video" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
</div>
<Form.Item name="description" label="简短说明">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
</Form.Item>
<Form.Item name="code" label="模型代码" rules={[{ required: true, message: "请填写模型代码" }]}>
<Input placeholder="例如:seedance-video" />
<Form.Item name="capability" label="类型">
<Segmented
block
options={[
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
{ label: "音频", value: "audio" },
]}
/>
</Form.Item>
<Form.Item name="icon" label="模型 Logo"><ModelIconPicker /></Form.Item>
</div>
<Form.Item name="description" label="简短说明">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
</Form.Item>
<Form.Item name="capability" label="类型">
<Segmented block options={[{ label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]} />
</Form.Item>
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
</EditorSection>
</>,
<Form.Item name="sortOrder" label="前台排序">
<InputNumber className="w-full" precision={0} />
</Form.Item>
</EditorSection>
</>
),
},
{
key: "routes",
label: "供应线路",
children: <>
<EditorSection icon={<GitBranch className="size-4" />} title="供应线路">
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</EditorSection>
<EditorSection title="系统规格价格"><PricingFields /></EditorSection>
</>,
children: (
<>
<EditorSection icon={<GitBranch className="size-4" />} title="供应线路">
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
</EditorSection>
<EditorSection title="系统规格价格">
<PricingFields />
</EditorSection>
</>
),
},
{
key: "capabilities",
label: "能力与参数",
children: <>
<EditorSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
<CapabilityScopeEditor capability={modelCapability} sourceSpecs={modelSourceSpecs} mode="front" />
</Form.Item>
</EditorSection>
<EditorSection title="默认参数">
<Form.Item name="defaultOptions" noStyle>
<DefaultOptionsEditor spec={modelCapabilitySpec} />
</Form.Item>
</EditorSection>
</>,
children: (
<>
<EditorSection title="创作端可选能力">
<Form.Item name="capabilitySpec" noStyle>
<CapabilityScopeEditor capability={modelCapability} sourceSpecs={modelSourceSpecs} mode="front" />
</Form.Item>
</EditorSection>
<EditorSection title="默认参数">
<Form.Item name="defaultOptions" noStyle>
<DefaultOptionsEditor spec={modelCapabilitySpec} />
</Form.Item>
</EditorSection>
</>
),
},
]}
>
@@ -459,7 +481,9 @@ export default function LogicalModelsPage() {
modelForm.setFieldsValue({ routes: [], capabilitySpec: emptyCapabilitySpec(changedValues.capability), defaultOptions: {}, pricePolicy: "channel", billingMode: "fixed_request" });
}}
>
<Form.Item name="enabled" hidden><Switch /></Form.Item>
<Form.Item name="enabled" hidden>
<Switch />
</Form.Item>
{tabs}
</Form>
)}

View File

@@ -66,7 +66,7 @@ test("model editor presents protocols in a searchable inline radio browser inste
Bun.file(new URL("../src/pages/admin/components/channel-model-editor.tsx", import.meta.url)).text(),
Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(),
]);
const protocolSection = sourceSection(source, '<Form.Item\n className="admin-model-protocol-field"', "{protocolError && (");
const protocolSection = sourceSection(compactSource(source), '<Form.Item className="admin-model-protocol-field"', "{protocolError && (");
expect(protocolSection).toContain("<ModelProtocolBrowser");
expect(protocolSection).not.toContain("<Select");

View File

@@ -58,7 +58,7 @@ describe("canvas screen-space background", () => {
setProperty: (name: string, value: string) => properties.set(name, value),
},
dataset: { canvasViewportInteracting: "true" },
querySelector: (selector: string) => selector === "[data-canvas-world-layer]" ? world : grid,
querySelector: (selector: string) => (selector === "[data-canvas-world-layer]" ? world : grid),
});
const graphics: ViewportTransform[] = [];
const previews: ViewportTransform[] = [];