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:
co-authored by
Claude Opus 4.8
parent
304a083184
commit
9c2a3bc365
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user