feat(prompts): admin「提示词」页 —— 视频线 6 条生图/视频提示词正文+比例可在后台改,不再写死

诉求:生图/视频提示词全写死在代码里,产品方看不到也改不了(之前那版只做成「质量后缀」不是正文)。
现新增可编辑「提示词模板」体系,覆盖视频项目生产线 6 条:人物立绘 / 人物三视图 / 商品三视图 /
场景图 / 分镜图 / 视频。

后端:
- 新模型 PromptTemplate(key 固定 6 条 + template 正文 + ratio 比例 + enabled);迁移 0012/0013
  种入默认正文(= 各 builder 现写死文本,零回归)。
- render_prompt(key, default, **字段):读 DB 模板,留空/停用回落默认;安全替换 {占位符}(中文名),
  未知占位符原样保留(改错不致命)。prompt_ratio_size:比例→gpt-image 尺寸。
- 6 个 builder/调用点改走模板;5 条图比例可改(gpt-image 仅 1:1/竖/横),视频固定 Seedance 9:16。
  场景图新增可编辑「场景风格」包装(默认仅 {场景描述},零回归)。人物/分镜/视频原质量后缀并入模板。
- adminpanel CRUD:GET /prompt-templates、PATCH /<id>(改正文/比例/启用,key 不可改、不增删,带审计)。

前端:admin 新增「提示词」侧栏页(/admin/prompts):6 卡,每卡=占位符提示+正文文本框+比例按钮+
启用开关+保存。复用 .btn/.card-hard 共享类,仅 token 配色,单橙锚点。

验证:Django 套件 102 全绿(含新模型/API/生成侧立即生效 + 人物提示词改读模板);tsc+vite build 绿;
无头(独立 sqlite 栈)真开 /admin/prompts:6 卡齐、文本框/比例/占位符齐、0 控制台错误。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-23 03:19:07 +08:00
co-authored by Claude Opus 4.8
parent 304a083184
commit 9c2a3bc365
13 changed files with 485 additions and 64 deletions
+20
View File
@@ -293,3 +293,23 @@
.gov-card.warn .gov-count { color: var(--heat); }
.gov-label { font-size: 12px; color: var(--black-alpha-56); }
.gov-total { margin-left: auto; font-size: 12px; color: var(--black-alpha-48); }
/* 提示词模板页(仅 token,8px 圆角,单橙 accent) */
.pt-list { display: flex; flex-direction: column; gap: 14px; }
.pt-card { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
.pt-card.pt-off { opacity: .6; }
.pt-head { display: flex; align-items: center; gap: 10px; }
.pt-title { font-size: 14px; font-weight: 600; color: var(--accent-black); }
.pt-key { font-size: 11.5px; color: var(--black-alpha-40); }
.pt-head .spacer { flex: 1; }
.pt-enabled { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); cursor: pointer; }
.pt-vars { font-size: 11.5px; color: var(--black-alpha-48); display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.pt-vars code { background: var(--heat-12); color: var(--heat); border-radius: var(--r-sm); padding: 1px 6px; }
.pt-vars-hint { color: var(--black-alpha-40); }
.pt-textarea { width: 100%; box-sizing: border-box; background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 13px; line-height: 1.6; color: var(--accent-black); font-family: var(--font-mono); resize: vertical; outline: none; transition: border-color var(--t-base), box-shadow var(--t-base); }
.pt-textarea:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); }
.pt-foot { display: flex; align-items: center; gap: 8px; }
.pt-ratio-label { font-size: 11.5px; color: var(--black-alpha-48); }
.pt-ratios { display: inline-flex; gap: 6px; }
.pt-ratio-na { font-size: 12px; color: var(--black-alpha-40); }
.pt-foot .spacer { flex: 1; }
+7
View File
@@ -651,6 +651,13 @@ export const adminApi = {
deleteQualityWord(id: string) {
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" });
},
// 生图/视频提示词模板(视频线 6 条):列表 + 改正文/比例/启用
promptTemplates() {
return request<import("./types").AdminPromptTemplate[]>("/api/admin/prompt-templates/");
},
updatePromptTemplate(id: string, payload: { template?: string; ratio?: string; enabled?: boolean }) {
return request<import("./types").AdminPromptTemplate>(`/api/admin/prompt-templates/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
assetReviews(params?: { review_status?: string; search?: string; page?: number; page_size?: number }) {
const qs = new URLSearchParams();
if (params?.review_status) qs.set("review_status", params.review_status);
@@ -7,6 +7,7 @@ import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing";
import { AdminGovernancePage } from "./admin-governance";
import { AdminInvitesPage } from "./admin-invites";
import { AdminModelsPage } from "./admin-models";
import { AdminPromptsPage } from "./admin-prompts";
import { AdminQualityPage } from "./admin-quality";
import { AdminReviewsPage } from "./admin-reviews";
import { AdminTasksPage } from "./admin-tasks";
@@ -30,6 +31,7 @@ export const ADMIN_SECTIONS: AdminSection[] = [
{ slug: "invites", label: "邀请码", icon: "ticket", group: "团队 · 用户", phase: 2 },
{ slug: "teams", label: "团队", icon: "building", group: "团队 · 用户", phase: 3 },
{ slug: "users", label: "用户", icon: "users", group: "团队 · 用户", phase: 3 },
{ slug: "prompts", label: "提示词", icon: "sparkles", group: "内容", phase: 4 },
{ slug: "quality", label: "质量词", icon: "type", group: "内容", phase: 4 },
{ slug: "reviews", label: "资产审核", icon: "shield", group: "内容", phase: 5 },
{ slug: "tasks", label: "任务监控", icon: "activity", group: "生成", phase: 6 },
@@ -180,6 +182,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "users") {
return <AdminUsersPage notify={notify} />;
}
if (section.slug === "prompts") {
return <AdminPromptsPage notify={notify} />;
}
if (section.slug === "quality") {
return <AdminQualityPage notify={notify} />;
}
@@ -0,0 +1,125 @@
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" }
];
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>
)}
</>
);
}
+11
View File
@@ -82,6 +82,17 @@ export type AdminQualityWord = {
enabled: boolean;
created_at: string;
};
export type AdminPromptTemplate = {
id: string;
key: string;
label: string;
template: string;
ratio: string; // "" 默认 | "1:1" | "portrait" 竖 | "landscape" 横
enabled: boolean;
placeholders: string[];
updated_at: string;
};
export type AdminReviewAsset = {
id: string;
name: string;