Files
yingqing/core/frontend/src/routes/admin/admin-prompts.tsx
T
seaislee1209andClaude Opus 4.8 fd596776e2 feat(image): 支持真 16:9(1536x864)+ 人物/商品三视图/场景默认 16:9
- 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>
2026-06-24 00:56:33 +08:00

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>
)}
</>
);
}