- gpt-image-2 实测支持 16:9(宽高需被 16 整除):真 16:9 = 1536x864(长边封顶 1536); 原来「16:9」误给成 1536x1024(实为 3:2)。修正 _RATIO_TO_SIZE 与 _ratio_to_image_size - admin「提示词」页比例按钮新增「宽 16:9」 - 迁移 0015/0016:scene + product_triview + person_triview 默认比例设为 16:9; 分镜图(storyboard_frame)/立绘/视频保持竖屏不动 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
5.2 KiB
TypeScript
127 lines
5.2 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { adminApi } from "../../api";
|
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
|
import type { AdminPromptTemplate } from "../../types";
|
|
|
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
|
|
|
// gpt-image 仅支持这 3 种成品尺寸;"" = 用各 builder 的写死默认
|
|
const RATIOS = [
|
|
{ key: "", label: "默认" },
|
|
{ key: "1:1", label: "1:1 方" },
|
|
{ key: "portrait", label: "竖 2:3" },
|
|
{ key: "landscape", label: "横 3:2" },
|
|
{ key: "16:9", label: "宽 16:9" }
|
|
];
|
|
|
|
export function AdminPromptsPage({ notify }: { notify: Notify }) {
|
|
const [rows, setRows] = useState<AdminPromptTemplate[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [draft, setDraft] = useState<Record<string, { template: string; ratio: string }>>({});
|
|
const [saving, setSaving] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const list = await adminApi.promptTemplates();
|
|
setRows(list);
|
|
setDraft(Object.fromEntries(list.map((r) => [r.id, { template: r.template, ratio: r.ratio }])));
|
|
} catch {
|
|
notify("error", "加载提示词模板失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
useEffect(() => { void load(); }, [load]);
|
|
|
|
async function save(r: AdminPromptTemplate) {
|
|
const d = draft[r.id];
|
|
if (!d) return;
|
|
setSaving(r.id);
|
|
try {
|
|
const u = await adminApi.updatePromptTemplate(r.id, { template: d.template, ratio: d.ratio });
|
|
setRows((l) => l.map((x) => (x.id === r.id ? u : x)));
|
|
notify("success", `已保存「${r.label}」`);
|
|
} catch {
|
|
notify("error", "保存失败");
|
|
} finally {
|
|
setSaving(null);
|
|
}
|
|
}
|
|
|
|
async function toggle(r: AdminPromptTemplate, enabled: boolean) {
|
|
try {
|
|
const u = await adminApi.updatePromptTemplate(r.id, { enabled });
|
|
setRows((l) => l.map((x) => (x.id === r.id ? u : x)));
|
|
} catch {
|
|
notify("error", "切换失败");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="page-head">
|
|
<div>
|
|
<h1>提示词</h1>
|
|
<div className="sub"><span className="mono">// 视频线 6 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
|
|
</div>
|
|
</div>
|
|
{loading ? (
|
|
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching prompt templates</p></div>
|
|
) : (
|
|
<div className="pt-list">
|
|
{rows.map((r) => {
|
|
const d = draft[r.id] || { template: r.template, ratio: r.ratio };
|
|
const dirty = d.template !== r.template || d.ratio !== r.ratio;
|
|
const isVideo = r.key === "video_segment";
|
|
return (
|
|
<div key={r.id} className={`card-hard pt-card${r.enabled ? "" : " pt-off"}`}>
|
|
<div className="pt-head">
|
|
<span className="pt-title">{r.label}</span>
|
|
<span className="mono pt-key">// {r.key}</span>
|
|
<span className="spacer" />
|
|
<label className="pt-enabled">
|
|
<input type="checkbox" checked={r.enabled} onChange={(e) => void toggle(r, e.target.checked)} />
|
|
<span>{r.enabled ? "已启用" : "已停用 · 用默认"}</span>
|
|
</label>
|
|
</div>
|
|
{r.placeholders.length > 0 && (
|
|
<div className="pt-vars mono">
|
|
占位符 {r.placeholders.map((p) => <code key={p}>{`{${p}}`}</code>)}
|
|
<span className="pt-vars-hint">· 运行时自动替换,保留这些占位符即可</span>
|
|
</div>
|
|
)}
|
|
<textarea
|
|
className="pt-textarea"
|
|
value={d.template}
|
|
rows={Math.min(10, Math.max(4, d.template.split("\n").length))}
|
|
spellCheck={false}
|
|
onChange={(e) => setDraft((m) => ({ ...m, [r.id]: { ...d, template: e.target.value } }))}
|
|
/>
|
|
<div className="pt-foot">
|
|
<span className="pt-ratio-label mono">比例</span>
|
|
{isVideo ? (
|
|
<span className="pt-ratio-na mono">视频固定竖屏 9:16(Seedance)</span>
|
|
) : (
|
|
<div className="pt-ratios">
|
|
{RATIOS.map((rt) => (
|
|
<button key={rt.key} type="button" className={`btn btn-sm${d.ratio === rt.key ? " btn-primary" : ""}`}
|
|
onClick={() => setDraft((m) => ({ ...m, [r.id]: { ...d, ratio: rt.key } }))}>{rt.label}</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<span className="spacer" />
|
|
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving === r.id} onClick={() => void save(r)}>
|
|
{saving === r.id ? "保存中…" : "保存"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|