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
+25 -1
View File
@@ -3,7 +3,7 @@ from decimal import Decimal
from rest_framework import serializers
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.billing.models import CreditLedger, QuotaPolicy
from apps.projects.models import Project
@@ -25,6 +25,30 @@ class QualityWordSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "created_at"]
# 每条提示词可用的 {占位符}(给 admin 提示;运行时由对应 builder 注入,改错会原样保留不致命)
PROMPT_PLACEHOLDERS = {
"person_portrait": ["描述"],
"person_triview": [],
"product_triview": ["商品", "补充"],
"scene": ["场景描述"],
"storyboard_frame": ["设定", "场景上下文", "时长", "脚本", "补充"],
"video_segment": ["设定", "分镜", "脚本", "时长"],
}
class PromptTemplateSerializer(serializers.ModelSerializer):
label = serializers.CharField(source="get_key_display", read_only=True)
placeholders = serializers.SerializerMethodField()
class Meta:
model = PromptTemplate
fields = ["id", "key", "label", "template", "ratio", "enabled", "placeholders", "updated_at"]
read_only_fields = ["id", "key", "label", "placeholders", "updated_at"] # key 固定,只改正文/比例/启用
def get_placeholders(self, obj) -> list:
return PROMPT_PLACEHOLDERS.get(obj.key, [])
class AdminReviewAssetSerializer(serializers.ModelSerializer):
"""跨团队人像审核队列行:复用 AssetFileSerializer 的首图 preview_url。"""
+40 -11
View File
@@ -210,21 +210,24 @@ class AdminQualityWordTests(TestCase):
r = self.ac.post("/api/admin/quality-words/", {"stage": "bogus", "text": "x"}, format="json")
self.assertEqual(r.status_code, 400)
def test_generation_reads_config_with_fallback(self):
from apps.ai.models import QualityWord
def test_person_prompt_reads_template_with_fallback(self):
"""人物立绘提示词改由「提示词模板」(person_portrait)驱动,取代旧质量词:
种子模板 = 原默认正文(描述填进 {描述});改模板 → 生成立即用新正文。"""
from apps.ai.models import PromptTemplate
from apps.ai.services import build_person_frontal_prompt
# 无配置 → 回落写死值
self.assertIn("纯色背景", build_person_frontal_prompt("小姐姐"))
# 配置后 → 生成用新词,描述仍保留
QualityWord.objects.create(stage="person", text="赛博朋克风", sort=0)
QualityWord.objects.create(stage="person", text="霓虹光", sort=1)
# 种子模板 → 含默认尾串,{描述} 被填入
out = build_person_frontal_prompt("小姐姐")
self.assertIn("赛博朋克风", out)
self.assertIn("霓虹光", out)
self.assertIn("纯色背景", out)
self.assertIn("小姐姐", out)
self.assertNotIn("纯色背景", out)
# 后台改模板 → 生成用新正文,{描述} 仍替换
PromptTemplate.objects.filter(key="person_portrait").update(template="赛博朋克风{描述}霓虹光")
out2 = build_person_frontal_prompt("小姐姐")
self.assertIn("赛博朋克风", out2)
self.assertIn("霓虹光", out2)
self.assertIn("小姐姐", out2)
self.assertNotIn("纯色背景", out2)
def test_disabled_word_not_used(self):
from apps.ai.models import QualityWord
@@ -236,6 +239,32 @@ class AdminQualityWordTests(TestCase):
self.assertIn("启用词", words)
self.assertNotIn("停用词", words)
def test_prompt_templates_list_update_permission(self):
"""提示词模板:列表(6 条,带 label/placeholders)+ 改正文比例 + 权限 + 审计 + 生成侧立即生效。"""
from apps.ai.services import build_product_triview_prompt_refs
self.assertEqual(self.nc.get("/api/admin/prompt-templates/").status_code, 403)
lst = self.ac.get("/api/admin/prompt-templates/")
self.assertEqual(lst.status_code, 200)
self.assertEqual(len(lst.data), 6) # 视频线 6 条
row = next(r for r in lst.data if r["key"] == "product_triview")
self.assertEqual(row["label"], "商品三视图")
self.assertIn("商品", row["placeholders"])
up = self.ac.patch(
f"/api/admin/prompt-templates/{row['id']}/",
{"template": "参考图1是「{商品}」是商品。生成三视图。", "ratio": "portrait"},
format="json",
)
self.assertEqual(up.status_code, 200)
self.assertTrue(AdminAuditLog.objects.filter(action="prompt_template.update").exists())
class P:
title = "杂鱼煲"
out = build_product_triview_prompt_refs(P(), "")
self.assertEqual(out, "参考图1是「杂鱼煲」是商品。生成三视图。") # 生成侧立即用新模板,且 {商品} 已替换
class AdminAssetReviewTests(TestCase):
"""Phase 5:跨团队人像审核队列(列/筛/批量送审/轮询)+ 权限。火山调用全 mock。"""
+4
View File
@@ -19,6 +19,8 @@ from .views import (
admin_task_detail,
admin_task_retry,
admin_tasks,
admin_prompt_template_detail,
admin_prompt_templates,
admin_quality_word_detail,
admin_quality_words,
admin_revoke_invitation,
@@ -42,6 +44,8 @@ urlpatterns = [
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
path("quality-words/", admin_quality_words, name="admin-quality-words"),
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
path("prompt-templates/<uuid:template_id>/", admin_prompt_template_detail, name="admin-prompt-template-detail"),
path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"),
path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"),
path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"),
+36 -1
View File
@@ -12,7 +12,7 @@ from apps.accounts.audit import log_admin_action
from apps.accounts.models import Invitation, Team, User
from apps.accounts.permissions import IsPlatformAdmin
from apps.accounts.serializers import InvitationSerializer
from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
from apps.assets.models import Asset
from apps.assets.review import poll_asset_review, submit_asset_for_review
from apps.billing.models import CreditLedger, QuotaPolicy
@@ -34,6 +34,7 @@ from .serializers import (
AdminTeamMemberSerializer,
AdminTeamSerializer,
AdminUserSerializer,
PromptTemplateSerializer,
QualityWordSerializer,
)
@@ -281,6 +282,40 @@ def admin_quality_word_detail(request, word_id):
return Response(QualityWordSerializer(obj).data)
# ─────────────────────────── 生图/视频 提示词模板 ───────────────────────────
@api_view(["GET"])
@permission_classes([IsPlatformAdmin])
def admin_prompt_templates(request):
"""列视频线 6 条提示词模板(固定 key,数量小不分页,按 key 排序)。"""
qs = PromptTemplate.objects.all().order_by("key")
return Response(PromptTemplateSerializer(qs, many=True).data)
@api_view(["PATCH"])
@permission_classes([IsPlatformAdmin])
def admin_prompt_template_detail(request, template_id):
"""改一条提示词模板的正文 / 比例 / 启用(key 固定不可改,不增不删)。"""
obj = PromptTemplate.objects.filter(id=template_id).first()
if obj is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
before = {"template": obj.template, "ratio": obj.ratio, "enabled": obj.enabled}
serializer = PromptTemplateSerializer(obj, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
log_admin_action(
request,
"prompt_template.update",
target_type="prompt_template",
target_id=obj.id,
target_name=obj.key,
before=before,
after={"template": obj.template, "ratio": obj.ratio, "enabled": obj.enabled},
)
return Response(PromptTemplateSerializer(obj).data)
# ─────────────────────────── 火山人像审核队列 ───────────────────────────
_REVIEW_STATUSES = {"", "processing", "active", "failed"}
@@ -0,0 +1,29 @@
# Generated by Django 5.1.15 on 2026-06-22 18:53
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0011_aitask_entity_extraction_type'),
]
operations = [
migrations.CreateModel(
name='PromptTemplate',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('key', models.CharField(choices=[('person_portrait', '人物立绘'), ('person_triview', '人物三视图'), ('product_triview', '商品三视图'), ('scene', '场景图'), ('storyboard_frame', '分镜图'), ('video_segment', '视频')], max_length=32, unique=True)),
('template', models.TextField(blank=True, default='')),
('ratio', models.CharField(blank=True, default='', max_length=12)),
('enabled', models.BooleanField(default=True)),
],
options={
'ordering': ['key'],
},
),
]
@@ -0,0 +1,60 @@
# 种入视频线 6 条提示词模板的默认值(= 各 builder 现在的写死文本,零回归)。
# 后台超管在 admin「提示词」页改正文/比例;留空或停用 → builder 回落写死默认。
from django.db import migrations
# key, 默认正文(含 {占位符}), 默认比例("portrait" 竖 / "landscape" 横)
SEED = [
(
"person_portrait",
"电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景",
"portrait",
),
(
"person_triview",
"参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景",
"landscape",
),
(
"product_triview",
"参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,"
"生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。"
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}",
"landscape",
),
(
"scene",
"{场景描述}",
"landscape",
),
(
"storyboard_frame",
"{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n"
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}",
"portrait",
),
(
"video_segment",
"{设定}{分镜}【脚本】{脚本}\n{时长}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。",
"portrait",
),
]
def seed(apps, schema_editor):
PromptTemplate = apps.get_model("ai", "PromptTemplate")
for key, template, ratio in SEED:
PromptTemplate.objects.update_or_create(
key=key, defaults={"template": template, "ratio": ratio, "enabled": True}
)
def unseed(apps, schema_editor):
PromptTemplate = apps.get_model("ai", "PromptTemplate")
PromptTemplate.objects.filter(key__in=[k for k, _, _ in SEED]).delete()
class Migration(migrations.Migration):
dependencies = [("ai", "0012_prompttemplate")]
operations = [migrations.RunPython(seed, unseed)]
+26
View File
@@ -135,3 +135,29 @@ class QualityWord(TimeStampedModel):
def __str__(self) -> str:
return f"{self.stage}/{self.slot}/{self.text}"
class PromptTemplate(TimeStampedModel):
"""平台可编辑的「生图/视频提示词模板」(视频项目生产线 6 条)。后台超管在 admin 改正文,
生成侧据 key 读取本表;留空/停用 → 回落各 builder 的写死默认(零回归)。
正文里 {占位符}(如 {商品}/{描述}/{脚本})运行时替换;未知占位符原样保留,改错不致命。
ratio:成品比例(空=按写死默认),gpt-image 仅 1:1 / portrait(竖) / landscape(横)三种。"""
class Key(models.TextChoices):
PERSON_PORTRAIT = "person_portrait", "人物立绘"
PERSON_TRIVIEW = "person_triview", "人物三视图"
PRODUCT_TRIVIEW = "product_triview", "商品三视图"
SCENE = "scene", "场景图"
STORYBOARD_FRAME = "storyboard_frame", "分镜图"
VIDEO_SEGMENT = "video_segment", "视频"
key = models.CharField(max_length=32, choices=Key.choices, unique=True)
template = models.TextField(blank=True, default="")
ratio = models.CharField(max_length=12, blank=True, default="") # "" / "1:1" / "portrait" / "landscape"
enabled = models.BooleanField(default=True)
class Meta:
ordering = ["key"]
def __str__(self) -> str:
return f"prompt:{self.key}"
+97 -51
View File
@@ -762,18 +762,46 @@ def quality_suffix(stage: str, fallback: str, slot: str = "quality", sep: str =
return sep.join(words) if words else fallback
_PROMPT_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}", re.UNICODE)
_RATIO_TO_SIZE = {"1:1": "1024x1024", "portrait": "1024x1536", "landscape": "1536x1024"}
def _prompt_template_row(key: str):
"""读 admin 可编辑的提示词模板行(启用的);缺表/缺行/异常一律返回 None(回落写死默认,零回归)。"""
try:
from apps.ai.models import PromptTemplate
return PromptTemplate.objects.filter(key=key, enabled=True).first()
except Exception: # noqa: BLE001 — 表还没迁移/查询异常都不该挡生成
return None
def render_prompt(key: str, default_text: str, **fields) -> str:
"""按 admin 可编辑模板渲染提示词。无模板/停用/正文空 → 回落 default_text(各 builder 的写死值)。
安全替换 {占位符}:已知字段换值,未知占位符**原样保留**(用户改错也不会让生成崩)。占位符名支持中文。"""
row = _prompt_template_row(key)
tpl = row.template if (row and (row.template or "").strip()) else default_text
return _PROMPT_PLACEHOLDER_RE.sub(lambda m: str(fields.get(m.group(1), m.group(0))), tpl)
def prompt_ratio_size(key: str, default_size: str) -> str:
"""模板配的比例 → gpt-image 尺寸(1024x1024 / 1024x1536 / 1536x1024);未配则用 default_size。"""
row = _prompt_template_row(key)
if row and row.ratio:
return _RATIO_TO_SIZE.get(row.ratio, default_size)
return default_size
def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。"""
"""商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图。正文可在 admin「提示词」页改;
{商品}=主图商品名(写死字段,不可删),{补充}=调用方附加文本。"""
name = (getattr(product, "title", "") or "商品").strip()
lines = [
f"参考图1是「{name}」的真实商品主图。",
"请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,生成同一件商品的三视图:",
"从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。",
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。",
]
if base_prompt and base_prompt.strip():
lines.append(base_prompt.strip())
return " ".join(lines)
default = (
"参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,"
"生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。"
"三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}"
)
return render_prompt("product_triview", default, 商品=name, 补充=(base_prompt or "").strip())
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
@@ -824,9 +852,9 @@ def build_person_frontal_prompt(description: str = "") -> str:
"""人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。
用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。"""
desc = (description or "").strip()
inner = f"{desc}" if desc else ""
tail = quality_suffix("person", "自然妆容,柔和影棚光,真实质感,单人,纯色背景")
return f"电商真人模特,氛围正面全身照,{inner}{tail}"
# 正文可在 admin「提示词」页改;{描述}=脚本提取/用户输入的人物描述(写死字段)。默认含原质量尾串。
default = "电商真人模特,氛围正面全身照,{描述}自然妆容,柔和影棚光,真实质感,单人,纯色背景"
return render_prompt("person_portrait", default, 描述=desc)
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
@@ -850,7 +878,8 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
elif kind == BaseAssetGroup.Kind.PERSON:
gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板
else:
gen_prompt = prompt
# 场景图:默认模板只是 {场景描述} 本身(零回归);可在 admin「提示词」页加全局场景风格
gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip())
payload = {
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
@@ -891,10 +920,15 @@ def run_base_asset_task(*, task_id: str) -> None:
reservation = task.credit_reservation
try:
if use_edit and ref_url:
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
# 商品三视图:比例可在 admin 改(默认横)
size = prompt_ratio_size("product_triview", "1536x1024")
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size)
else:
# 场景 = 16:9 横构图(环境/背景空镜);人物立绘 = 9:16 竖全身。默认竖。
gen_size = "1536x1024" if kind == BaseAssetGroup.Kind.SCENE else "1024x1536"
# 场景默认横、人物立绘默认竖;两者比例都可在 admin 改
if kind == BaseAssetGroup.Kind.SCENE:
gen_size = prompt_ratio_size("scene", "1536x1024")
else:
gen_size = prompt_ratio_size("person_portrait", "1024x1536")
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
media = provider.extract_first_media_url(response)
with transaction.atomic():
@@ -959,7 +993,9 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
if not hasattr(provider, "image_edit"):
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
ref_url = _asset_preview_url(portrait_asset)
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key, "reference_image": ref_url}
# 人物三视图提示词:正文可在 admin「提示词」页改(无占位符)
tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT)
payload = {"model": model_config.name, "prompt": tri_prompt, "kind": "person", "triview_of": asset_key, "reference_image": ref_url}
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
generate_triview_task.delay(str(task.id))
return task
@@ -983,7 +1019,8 @@ def run_triview_task(*, task_id: str) -> None:
provider = get_image_provider(model_config)
reservation = task.credit_reservation
try:
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
tri_size = prompt_ratio_size("person_triview", "1536x1024")
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
@@ -1067,18 +1104,23 @@ def _segment_script_text(segment) -> str:
def build_storyboard_frame_prompt(project, version, segment) -> str:
"""单镜导演故事板提示词(一镜 = 一张导演故事板,用户钦定结构)。无参考图时的文本版。"""
"""单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)"""
dur = segment.duration_seconds or 15
lines = [
"根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。",
_scene_context(project),
f"【分镜脚本】(本段时长约 {dur} 秒)",
_segment_script_text(segment) or f"{segment.sort_order + 1}",
]
if version.prompt:
lines.append(version.prompt.strip())
lines.append(quality_suffix("storyboard", "电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。"))
return "\n".join(line for line in lines if line)
default = (
"{设定}根据以下脚本生成一个导演故事板用于指导 seedance 的视频生成。\n{场景上下文}\n"
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}"
)
rendered = render_prompt(
"storyboard_frame", default,
设定="",
场景上下文=_scene_context(project),
时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + version.prompt.strip()) if version.prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
@@ -1094,21 +1136,20 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
storyboard_idx = n
else:
setup_parts.append(f"@图{n}{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})")
lines = []
if setup_parts:
lines.append("【设定】" + "".join(setup_parts) + "")
if storyboard_idx is not None:
lines.append(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。")
script_text = _segment_script_text(scene) if scene is not None else ""
extra = (user_prompt or "").strip()
if extra:
script_text = (script_text + "\n" + extra).strip() if script_text else extra
lines.append("【脚本】" + (script_text or f"{video_segment.sort_order + 1}"))
lines.append(
f"{video_segment.target_duration_seconds}s · "
+ quality_suffix("video", "9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。")
# 正文可在 admin「提示词·视频」页改。占位符:{设定}=@图N点名、{分镜}=分镜图引用、{脚本}=本段脚本、{时长}。
default = "{设定}{分镜}【脚本】{脚本}\n{时长}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。"
rendered = render_prompt(
"video_segment", default,
设定=("【设定】" + "".join(setup_parts) + "\n" if setup_parts else ""),
分镜=(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。\n" if storyboard_idx is not None else ""),
脚本=(script_text or f"{video_segment.sort_order + 1}"),
时长=video_segment.target_duration_seconds,
)
return "\n".join(line for line in lines if line)
return "\n".join(line for line in rendered.split("\n") if line.strip())
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
@@ -1223,17 +1264,22 @@ def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dic
f"@图{i + 1}{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
)
dur = segment.duration_seconds or 15
lines = [
f"【设定】{setup}",
"根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。",
_scene_context(project),
f"【分镜脚本】(本段时长约 {dur} 秒)",
_segment_script_text(segment) or f"{segment.sort_order + 1}",
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。",
]
if version.prompt:
lines.append(version.prompt.strip())
return "\n".join(line for line in lines if line)
# 正文可在 admin「提示词」页改。占位符:{设定}=@图N点名、{场景上下文}、{时长}、{脚本}=本镜脚本、{补充}=本版附加。
default = (
"{设定}根据以下脚本生成一个导演故事板用于指导 seedance 的视频生成。\n{场景上下文}\n"
"【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;"
"电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}"
)
rendered = render_prompt(
"storyboard_frame", default,
设定=(f"【设定】{setup}\n" if setup else ""),
场景上下文=_scene_context(project),
时长=dur,
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + version.prompt.strip()) if version.prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
def _is_transient_error(exc: Exception) -> bool:
+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;