feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)

* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
This commit is contained in:
huiku authored and GitHub committed 2026-10-13 19:18:21 +08:00
1 parent 88de4ce422
commit 7466119867
104 files changed
+6013 -568

No files matched your search

@@ -4,16 +4,21 @@ import { Check, Plus } from "lucide-react";
import { Switch } from "@/components/ui/base/switch";
import "./image-size-picker.css";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { DEFAULT_IMAGE_RATIOS, IMAGE_RATIOS, imageResolutionTiers, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { validateImageSize } from "@/services/api/image-validation";
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
const IMAGE_RESOLUTIONS = imageResolutionTiers(profile);
export function ImageSizePresetsEditor({ profile, disabled, onChange, tiers, ratios: allowedRatios, allowCustomRatio = true, grouped = true }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void; tiers?: string[]; ratios?: string[]; allowCustomRatio?: boolean; grouped?: boolean }) {
const IMAGE_RESOLUTIONS = tiers ?? imageResolutionTiers(profile);
const presets = imageSizePresets(profile);
const id = useId();
const [drafts, setDrafts] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const [errors, setErrors] = useState<Partial<Record<ImageResolutionTier, string>>>({});
const candidate = (tier: string, ratio: string) => {
const existing = presets.find((preset) => preset.tier === tier && preset.ratio === ratio);
if (existing) return existing;
try { return imagePresetForRatio(tier, ratio); } catch { return { tier, ratio, width: 0, height: 0, size: "" }; }
};
const toggleTier = (tier: ImageResolutionTier, ratios: string[], enabled: boolean) => {
if (disabled) return;
update(tier, enabled ? ratios : []);
@@ -24,8 +29,8 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
const next = [
...new Map(
ratios.map((ratio) => {
const normalized = imagePresetForRatio(tier, ratio);
const preset = presets.find((preset) => preset.tier === tier && preset.ratio === normalized.ratio) || normalized;
if (!/^\d+[::]\d+$/.test(ratio) || ratio.split(/[::]/).some((part) => Number(part) <= 0)) throw new Error("请输入正数比例,例如 16:9");
const preset = candidate(tier, ratio);
return [preset.ratio, preset] as const;
}),
).values(),
@@ -49,7 +54,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
const width = field === "width" ? value : selected.width;
const height = field === "height" ? value : selected.height;
try {
validateImageSize(width, height);
if (width > 0 && height > 0) validateImageSize(width, height, !profile.parameters);
const next = presets.map((preset) => preset === selected ? { ...preset, width, height, size: `${width}x${height}` } : preset);
onChange(imageSizeConfigWithPresets(profile, next));
setErrors((current) => ({ ...current, [tier]: "" }));
@@ -69,34 +74,35 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
};
return (
<div className="image-size-presets-editor">
<p className="image-size-editor-intro">点选支持的比例,再次点击可移除。尺寸自动换算,已有精确尺寸保持不变。</p>
<p className="image-size-editor-intro">点选支持的比例,再次点击可移除。已有精确尺寸保持不变,有换算规则时自动填入。</p>
{IMAGE_RESOLUTIONS.map((tier) => {
const items = presets.filter((preset) => preset.tier === tier);
const ratios = [...new Set([...IMAGE_RATIOS, ...items.map((item) => item.ratio)])];
const ratios = [...new Set([...(allowedRatios ?? IMAGE_RATIOS), ...items.map((item) => item.ratio)])];
return (
<section key={tier} className="image-size-preset-group" aria-labelledby={`${id}-${tier}-title`}>
<header className="image-size-preset-heading">
<section key={tier} className={`image-size-preset-group${grouped ? "" : " is-ungrouped"}`} aria-label={grouped ? undefined : "比例配置"} aria-labelledby={grouped ? `${id}-${tier}-title` : undefined}>
{grouped ? <header className="image-size-preset-heading">
<div className="image-size-preset-heading-label">
<strong id={`${id}-${tier}-title`}>{tier.toUpperCase()}</strong>
<span>{items.length ? `${items.length} 个比例` : "未启用"}</span>
<Switch size="sm" checked={items.length > 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} />
{!profile.parameters ? <Switch size="sm" checked={items.length > 0} disabled={disabled} aria-label={`启用 ${tier.toUpperCase()} 规格`} onChange={(enabled) => toggleTier(tier, ratios, enabled)} /> : null}
</div>
</header>
{!items.length && DEFAULT_IMAGE_RATIOS.some((ratio) => ratios.includes(ratio)) ? <Button size="small" disabled={disabled} onClick={() => update(tier, DEFAULT_IMAGE_RATIOS.filter((ratio) => ratios.includes(ratio)))}>选择常用比例</Button> : null}
</header> : null}
<div className="image-size-preset-body">
{imageResolutionUsesQuality(profile) && !imageTierAvailable(profile, tier) ? <p className="image-size-hint">需先配置此档位对应的质量值,用户端才会开放。</p> : null}
<div className="image-size-preset-options" role="group" aria-label={`${tier.toUpperCase()} 支持的宽高比`}>
<div className="image-size-preset-options" role="group" aria-label={grouped ? `${tier.toUpperCase()} 支持的宽高比` : "支持的宽高比"}>
{ratios.map((ratio) => {
const selected = items.find((item) => item.ratio === ratio);
const dimensions = selected || imagePresetForRatio(tier, ratio);
const dimensions = selected || candidate(tier, ratio);
return (
<div key={ratio} className="image-size-preset-option-wrap">
<button
type="button"
disabled={disabled}
aria-pressed={Boolean(selected)}
aria-label={`${tier.toUpperCase()} ${ratio}`}
aria-label={grouped ? `${tier.toUpperCase()} ${ratio}` : ratio}
className="image-size-preset-option"
title={`${ratio} · ${dimensions.width} × ${dimensions.height} px${selected ? " · 点击移除" : " · 点击添加"}`}
title={`${ratio}${dimensions.width && dimensions.height ? ` · ${dimensions.width} × ${dimensions.height} px` : ""}${selected ? " · 点击移除" : " · 点击添加"}`}
onClick={() => update(tier, selected ? items.filter((item) => item.ratio !== ratio).map((item) => item.ratio) : [...items.map((item) => item.ratio), ratio])}
>
<span className="image-size-preset-option-label">
@@ -104,19 +110,19 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
{selected ? <Check size={12} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
</span>
<small>
{dimensions.width} × {dimensions.height}
{dimensions.width && dimensions.height ? `${dimensions.width} × ${dimensions.height}` : "设置尺寸"}
</small>
</button>
{selected ? (
<div className="image-size-preset-dimensions" aria-label={`${tier.toUpperCase()} ${ratio} 精确像素`}>
<label>
<span>宽</span>
<InputNumber min={1} precision={0} value={selected.width} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 宽度像素`} onChange={(value) => editDimensions(tier, ratio, "width", value)} />
<InputNumber controls={false} min={1} precision={0} value={selected.width || null} disabled={disabled} aria-label={`${grouped ? tier.toUpperCase() + " " : ""}${ratio} 宽度像素`} onChange={(value) => editDimensions(tier, ratio, "width", value)} />
</label>
<span aria-hidden="true">×</span>
<label>
<span>高</span>
<InputNumber min={1} precision={0} value={selected.height} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 高度像素`} onChange={(value) => editDimensions(tier, ratio, "height", value)} />
<InputNumber controls={false} min={1} precision={0} value={selected.height || null} disabled={disabled} aria-label={`${grouped ? tier.toUpperCase() + " " : ""}${ratio} 高度像素`} onChange={(value) => editDimensions(tier, ratio, "height", value)} />
</label>
</div>
) : null}
@@ -124,7 +130,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
);
})}
</div>
<div className="image-size-preset-add">
{allowCustomRatio ? <div className="image-size-preset-add">
<Input
aria-label={`${tier.toUpperCase()} 自定义比例`}
aria-invalid={Boolean(errors[tier])}
@@ -145,7 +151,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
<Button disabled={disabled || !drafts[tier]?.trim()} icon={<Plus size={14} />} onClick={() => addRatio(tier)} aria-label={`添加 ${tier.toUpperCase()} 比例`}>
添加
</Button>
</div>
</div> : null}
{errors[tier] ? (
<p id={`${id}-${tier}-error`} role="alert" className="image-size-error">
{errors[tier]}
@@ -163,7 +169,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
<div className="image-size-default-options" role="group" aria-label="默认输出">
{profile.size.values.map((value) => {
const preset = presets.find((item) => (profile.size.parameter === "aspect_ratio" ? item.ratio === value : item.size === value));
const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${preset.tier.toUpperCase()} · ${preset.ratio}` : value;
const label = value === "auto" ? "自动" : preset && profile.size.parameter === "size" ? `${grouped ? preset.tier.toUpperCase() + " · " : ""}${preset.ratio}` : value;
return (
<button key={value} type="button" disabled={disabled} aria-pressed={profile.size.default === value} title={value} aria-label={`默认输出 ${label} ${value}`} onClick={() => onChange({ ...profile.size, default: value })}>
{label}