feat(*): 分镜创作体系 - 重构画风、提示词模板与视频生成链路

This commit is contained in:
ddCat
2026-08-04 15:03:41 +08:00
parent 98382ba976
commit d4018123e1
46 changed files with 2667 additions and 572 deletions

View File

@@ -25,7 +25,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
items: [
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
{ path: "/admin/storyboard-prompts", label: "分镜提示词", description: "Agent 提示词版本", icon: <MessageSquareText className="size-4" /> },
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
],
},
{

View File

@@ -1,45 +1,68 @@
import { App, Button, Drawer, Form, Input, Select, Table, Tag } from "antd";
import { Alert, App, Button, Drawer, Form, Input, Popconfirm, Select, Table, Tabs, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Copy, Info, Pencil, Plus, Power, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Braces, Copy, FileJson, FileText, Info, Plus, Power, Search, ShieldCheck, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { createAdminStoryboardPromptTemplate, deleteAdminStoryboardPromptTemplate, listAdminStoryboardPromptTemplates, updateAdminStoryboardPromptTemplate, type StoryboardPromptTemplate, type StoryboardPromptVariable } from "@/services/api/auth";
import { PromptCodeEditor, type PromptCodeEditorHandle } from "@/components/prompt/prompt-code-editor";
import {
createAdminPromptTemplate,
deleteAdminPromptTemplate,
listAdminPromptTemplates,
updateAdminPromptTemplate,
type PromptOperationDefinition,
type PromptTemplate,
} from "@/services/api/auth";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton } from "../components/admin-ui";
type PromptFormValues = { name: string; content: string; enabled?: boolean };
type PromptFormValues = { name: string; enabled?: boolean };
type DraftBaseline = { operation: string; name: string; enabled: boolean; content: string };
export default function StoryboardPromptsPage() {
const { message, modal } = App.useApp();
const { message } = App.useApp();
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("filter") || "";
const operationFilter = searchParams.get("operation") || "all";
const status = searchParams.get("status") === "enabled" || searchParams.get("status") === "disabled" ? searchParams.get("status") as "enabled" | "disabled" : "all";
const [templates, setTemplates] = useState<StoryboardPromptTemplate[]>([]);
const [variables, setVariables] = useState<StoryboardPromptVariable[]>([]);
const [templates, setTemplates] = useState<PromptTemplate[]>([]);
const [definitions, setDefinitions] = useState<PromptOperationDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<StoryboardPromptTemplate | null>(null);
const [baseTemplate, setBaseTemplate] = useState<PromptTemplate | null>(null);
const [draftOperation, setDraftOperation] = useState("");
const [pendingOperation, setPendingOperation] = useState("");
const [draftBaseline, setDraftBaseline] = useState<DraftBaseline | null>(null);
const [editorContent, setEditorContent] = useState("");
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<PromptFormValues>();
const hasFilters = Boolean(keyword || status !== "all");
const editorRef = useRef<PromptCodeEditorHandle>(null);
const draftName = Form.useWatch("name", form) || "";
const draftEnabled = Form.useWatch("enabled", form) === true;
const selectedDefinition = definitions.find((item) => item.operation === draftOperation);
const hasFilters = Boolean(keyword || operationFilter !== "all" || status !== "all");
const dirty = Boolean(draftBaseline) && (
draftOperation !== draftBaseline?.operation
|| draftName !== draftBaseline?.name
|| draftEnabled !== draftBaseline?.enabled
|| editorContent !== draftBaseline?.content
);
const updateUrl = (patch: { filter?: string; status?: string }) => {
const updateUrl = (patch: { filter?: string; operation?: string; status?: string }) => {
const next = new URLSearchParams(searchParams);
if (patch.filter !== undefined) patch.filter ? next.set("filter", patch.filter) : next.delete("filter");
if (patch.status !== undefined) patch.status !== "all" ? next.set("status", patch.status) : next.delete("status");
Object.entries(patch).forEach(([key, value]) => value && value !== "all" ? next.set(key, value) : next.delete(key));
setSearchParams(next);
};
const reload = async () => {
setLoading(true);
try {
const result = await listAdminStoryboardPromptTemplates();
const result = await listAdminPromptTemplates();
setTemplates(result.templates);
setVariables(result.variables);
setDefinitions(result.definitions);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取分镜提示词失败");
message.error(error instanceof Error ? error.message : "读取提示词模板失败");
} finally {
setLoading(false);
}
@@ -47,91 +70,189 @@ export default function StoryboardPromptsPage() {
useEffect(() => { void reload(); }, []);
const definitionByOperation = useMemo(() => new Map(definitions.map((item) => [item.operation, item])), [definitions]);
const filtered = useMemo(() => templates.filter((template) => {
const definition = definitionByOperation.get(template.operation);
const normalizedKeyword = keyword.trim().toLowerCase();
if (normalizedKeyword && !`${template.name} ${template.content}`.toLowerCase().includes(normalizedKeyword)) return false;
if (normalizedKeyword && !`${template.name} ${template.content} ${definition?.label || ""}`.toLowerCase().includes(normalizedKeyword)) return false;
if (operationFilter !== "all" && template.operation !== operationFilter) return false;
if (status === "enabled" && !template.enabled) return false;
if (status === "disabled" && template.enabled) return false;
return true;
}), [keyword, status, templates]);
}), [definitionByOperation, keyword, operationFilter, status, templates]);
const openDrawer = (template?: StoryboardPromptTemplate) => {
setEditing(template || null);
const activeTemplateFor = (operation: string) => templates.find((template) => template.operation === operation && template.enabled);
const loadDraftBaseline = (operation: string, template?: PromptTemplate) => {
const source = template || activeTemplateFor(operation);
const baseline = {
operation,
name: source ? `${source.name} · 新版本` : "",
enabled: false,
content: source?.content || "",
};
setDraftOperation(operation);
setPendingOperation("");
setEditorContent(baseline.content);
setDraftBaseline(baseline);
form.setFieldsValue({ name: baseline.name, enabled: baseline.enabled });
};
const openDrawer = (template?: PromptTemplate) => {
const operation = template?.operation || definitions[0]?.operation || "";
setBaseTemplate(template || null);
form.resetFields();
form.setFieldsValue(template ? { name: template.name, content: template.content, enabled: template.enabled } : { name: "", content: "", enabled: false });
loadDraftBaseline(operation, template);
setDrawerOpen(true);
};
const switchOperation = (operation: string) => {
if (!dirty) {
loadDraftBaseline(operation);
return;
}
setPendingOperation(operation);
};
const closeDrawer = () => {
if (saving) return;
if (!form.isFieldsTouched()) return setDrawerOpen(false);
modal.confirm({ title: "放弃提示词修改?", content: "尚未保存的版本内容将丢失。", okText: "放弃修改", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => setDrawerOpen(false) });
setDrawerOpen(false);
setPendingOperation("");
};
const save = async () => {
const values = await form.validateFields();
if (!editorContent.trim()) {
message.warning("请填写提示词模板内容");
return;
}
setSaving(true);
try {
const payload = { name: values.name.trim(), content: values.content, enabled: values.enabled === true };
await (editing ? updateAdminStoryboardPromptTemplate(editing.id, payload) : createAdminStoryboardPromptTemplate(payload));
await createAdminPromptTemplate({ operation: draftOperation, name: values.name.trim(), content: editorContent, enabled: values.enabled === true });
setDraftBaseline(null);
setDrawerOpen(false);
form.resetFields();
await reload();
message.success(editing ? "分镜提示词已更新" : "分镜提示词已创建");
message.success("提示词新版本已创建");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存分镜提示词失败");
message.error(error instanceof Error ? error.message : "保存提示词模板失败");
} finally {
setSaving(false);
}
};
const activate = async (template: StoryboardPromptTemplate) => {
const activate = async (template: PromptTemplate) => {
try {
await updateAdminStoryboardPromptTemplate(template.id, { name: template.name, content: template.content, enabled: true });
await updateAdminPromptTemplate(template.id, { operation: template.operation, name: template.name, content: template.content, enabled: true });
await reload();
message.success("分镜提示词已启用");
message.success("提示词版本已启用");
} catch (error) {
message.error(error instanceof Error ? error.message : "启用分镜提示词失败");
message.error(error instanceof Error ? error.message : "启用提示词版本失败");
}
};
const remove = async (template: StoryboardPromptTemplate) => {
const remove = async (template: PromptTemplate) => {
try {
await deleteAdminStoryboardPromptTemplate(template.id);
await deleteAdminPromptTemplate(template.id);
await reload();
message.success("分镜提示词已删除");
message.success("提示词版本已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "删除分镜提示词失败");
message.error(error instanceof Error ? error.message : "删除提示词版本失败");
}
};
const columns: ColumnsType<StoryboardPromptTemplate> = [
{ title: "模板", dataIndex: "name", render: (_, template) => <div><div className="font-medium">{template.name}</div><div className="text-xs text-foreground/45">{template.content.length} 字符</div></div> },
{ title: "内容摘要", dataIndex: "content", width: 420, render: (content: string) => <div className="line-clamp-2 whitespace-pre-wrap text-xs leading-5 text-foreground/65">{content}</div> },
{ title: "状态", dataIndex: "enabled", width: 110, render: (enabled) => <Tag variant="filled" color={enabled ? "success" : "default"}>{enabled ? "启用中" : "未启用"}</Tag> },
const columns: ColumnsType<PromptTemplate> = [
{ title: "模板类型", dataIndex: "operation", width: 180, render: (operation: string) => { const definition = definitionByOperation.get(operation); return <div><div className="font-medium">{definition?.label || operation}</div><div className="mt-1 text-xs text-foreground/45">{definition?.category || "--"}</div></div>; } },
{ title: "版本", dataIndex: "name", render: (_, template) => <div><div className="font-medium">{template.name}</div><div className="mt-1 text-xs text-foreground/45">v{template.version} · {template.content.length} 字符</div></div> },
{ title: "输出", dataIndex: "outputType", width: 120, render: (outputType: string, template) => <span className="inline-flex items-center gap-1.5 text-xs text-foreground/65">{outputType === "json" ? <FileJson className="size-3.5" /> : <FileText className="size-3.5" />}{outputType === "json" ? definitionByOperation.get(template.operation)?.schemaKey || "JSON" : "文本"}</span> },
{ title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => <Tag variant="filled" color={enabled ? "success" : "default"}>{enabled ? "启用中" : "历史版"}</Tag> },
{ title: "更新时间", dataIndex: "updatedAt", width: 180, render: formatTime },
{ title: "操作", width: 140, fixed: "right", align: "right", render: (_, template) => <AdminRowActions primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: <Power className="size-3.5" />, disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "启用后会立即替换当前 Agent 分镜生成所使用的版本。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: <Trash2 className="size-3.5" />, danger: true, disabled: template.enabled, confirm: { title: "删除这个提示词版本?", description: "删除后不可恢复;启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> },
{ title: "操作", width: 170, fixed: "right", align: "right", render: (_, template) => <AdminRowActions primary={{ label: "基于此版本新建", icon: <Copy className="size-3.5" />, onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: <Power className="size-3.5" />, disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "只会替换同类型的当前版本,其他模板类型不受影响。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: <Trash2 className="size-3.5" />, danger: true, disabled: template.enabled, confirm: { title: "删除这个历史版本?", description: "删除后不可恢复,启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> },
];
const insertVariable = (placeholder: string) => {
const current = form.getFieldValue("content") || "";
form.setFieldValue("content", `${current}${current && !current.endsWith("\n") ? "\n" : ""}${placeholder}`);
};
return (
<AdminPageFrame title="分镜提示词" description="Agent 提示词版本" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增版本</Button>}>
<div className="border-b border-border pb-4 text-xs leading-5 text-foreground/60"><div className="flex items-center gap-1.5 font-medium text-foreground/80"><Info className="size-3.5" />真实用于 Agent 分镜生成</div><p>当前启用版本会与镜头时长、数量和电影化质量约束一起发送给文本模型。展开行可查看完整提示词。</p></div>
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", status: "all" })}>
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索版本名称或提示词" onChange={(event) => updateUrl({ filter: event.target.value })} />
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value })} options={[{ label: "全部状态", value: "all" }, { label: "启用中", value: "enabled" }, { label: "未启用", value: "disabled" }]} />
<AdminPageFrame title="提示词模板" description="平台创作策略与版本管理" actions={<Button type="primary" icon={<Plus className="size-4" />} disabled={!definitions.length} onClick={() => openDrawer()}>新建版本</Button>}>
<div className="border-b border-border pb-4 text-xs leading-5 text-foreground/60"><div className="flex items-center gap-1.5 font-medium text-foreground/80"><ShieldCheck className="size-3.5" />创作策略与执行契约分离</div><p>可以调整导演方法、镜头语言和生成策略。JSON Schema、动态项目数据和服务端校验属于受保护契约,只读展示且不会被模板覆盖。</p></div>
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", operation: "all", status: "all" })}>
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索模板或内容" onChange={(event) => updateUrl({ filter: event.target.value })} />
<Select className="w-40" value={operationFilter} onChange={(value) => updateUrl({ operation: value })} options={[{ label: "全部类型", value: "all" }, ...definitions.map((item) => ({ label: item.label, value: item.operation }))]} />
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value })} options={[{ label: "全部状态", value: "all" }, { label: "启用中", value: "enabled" }, { label: "历史版本", value: "disabled" }]} />
</ListToolbar>
<TableSurface>{loading && templates.length === 0 ? <AdminTableSkeleton rows={8} columns={5} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={filtered} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1080 }} expandable={{ expandedRowRender: (template) => <pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs leading-5 text-foreground/75">{template.content}</pre> }} />}</TableSurface>
<Drawer title={editing ? "编辑分镜提示词" : "新增分镜提示词"} open={drawerOpen} size="min(760px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="版本名称" rules={[{ required: true, message: "请填写版本名称" }]}><Input placeholder="例如:写实电影分镜 v2" /></Form.Item>
<Form.Item label="变量"><div className="flex flex-wrap gap-2">{variables.map((variable) => <Button key={variable.placeholder} size="small" icon={<Copy className="size-3.5" />} onClick={() => insertVariable(variable.placeholder)}>{variable.label}</Button>)}</div></Form.Item>
<Form.Item name="content" label="提示词模板" rules={[{ required: true, message: "请填写提示词模板" }]}><Input.TextArea rows={22} placeholder="使用变量占位符,例如 {{剧情}}、{{用户要求}}、{{画布资产}}" /></Form.Item>
<Form.Item name="enabled" label="保存后状态"><Select disabled={editing?.enabled} options={[{ label: "保留为未启用版本", value: false }, { label: "保存并设为当前启用版本", value: true }]} /></Form.Item>
<TableSurface>{loading && templates.length === 0 ? <AdminTableSkeleton rows={8} columns={6} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={filtered} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1120 }} expandable={{ expandedRowRender: (template) => <pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs leading-5 text-foreground/75">{template.content}</pre> }} />}</TableSurface>
<Drawer
title={baseTemplate ? `基于 v${baseTemplate.version} 新建版本` : "新建提示词版本"}
open={drawerOpen}
size="min(1180px, 100vw)"
onClose={closeDrawer}
closable={false}
maskClosable={false}
keyboard={false}
destroyOnHidden
styles={{ body: { padding: 0 } }}
extra={<div className="flex gap-2"><Popconfirm disabled={!dirty} title="放弃模板修改?" description="尚未保存的新版本内容将丢失。" okText="放弃修改" cancelText="继续编辑" okButtonProps={{ danger: true }} onConfirm={closeDrawer}><Button disabled={saving} onClick={() => { if (!dirty) closeDrawer(); }}>关闭</Button></Popconfirm><Button type="primary" loading={saving} disabled={!draftOperation || !draftName.trim() || !editorContent.trim()} onClick={() => void save()}>保存版本</Button></div>}
>
<Form form={form} layout="vertical" requiredMark={false} className="flex min-h-full flex-col">
<div className="grid shrink-0 gap-4 border-b border-border p-4 md:grid-cols-3">
<Form.Item label="模板类型" className="mb-0">
<Select value={draftOperation} disabled={Boolean(baseTemplate)} options={definitions.map((item) => ({ label: `${item.category} · ${item.label}`, value: item.operation }))} onChange={switchOperation} />
</Form.Item>
<Form.Item name="name" label="版本名称" className="mb-0" rules={[{ required: true, whitespace: true, message: "请填写版本名称" }]}>
<Input placeholder="例如:轻喜剧分镜策略 v2" />
</Form.Item>
<Form.Item name="enabled" label="保存后状态" className="mb-0">
<Select options={[{ label: "保存为历史版本", value: false }, { label: "保存并设为当前启用版本", value: true }]} />
</Form.Item>
</div>
{pendingOperation ? (
<Alert
type="warning"
showIcon
title="当前版本有未保存修改"
description={`切换到“${definitionByOperation.get(pendingOperation)?.label || pendingOperation}”会丢弃当前草稿。`}
action={<div className="flex gap-2"><Button size="small" onClick={() => setPendingOperation("")}>继续编辑</Button><Button size="small" danger onClick={() => loadDraftBaseline(pendingOperation)}>放弃并切换</Button></div>}
/>
) : null}
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-3">
<section className="flex min-h-0 flex-col border-b border-border p-4 lg:col-span-2 lg:border-b-0 lg:border-r">
<div className="mb-3 flex shrink-0 flex-wrap items-start justify-between gap-3">
<div>
<div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-semibold">模板内容</h3>{dirty ? <Tag variant="filled" color="warning">未保存</Tag> : null}</div>
<p className="mt-1 text-xs leading-5 text-foreground/50">这里只编辑创作策略。变量会插入到当前光标位置。</p>
</div>
<div className="flex flex-wrap justify-end gap-2">
{selectedDefinition?.variables.map((variable) => (
<Button key={variable.placeholder} size="small" icon={<Braces className="size-3.5" />} onClick={() => editorRef.current?.insertText(variable.placeholder)}>
插入{variable.label}
</Button>
))}
</div>
</div>
<div className="min-h-96 flex-1 overflow-hidden rounded-md border border-border">
<PromptCodeEditor ref={editorRef} value={editorContent} ariaLabel="提示词模板内容" onChange={setEditorContent} />
</div>
</section>
<aside className="min-h-0 p-4">
<Tabs
size="small"
items={[
{
key: "contract",
label: "输出契约",
children: <div><div className="mb-3 flex items-center gap-2 text-xs font-medium"><ShieldCheck className="size-4" />服务端只读</div><pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap text-xs leading-6 text-foreground/65">{selectedDefinition?.outputContract || "请选择模板类型"}</pre></div>,
},
{
key: "preview",
label: "最终结构",
children: <div className="space-y-4 text-xs leading-6"><section><div className="mb-2 font-medium text-foreground/80">可编辑创作策略</div><pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap text-foreground/65">{editorContent || "尚未填写"}</pre></section><section className="border-t border-border pt-4"><div className="mb-2 font-medium text-foreground/80">运行时强制追加</div><p className="text-foreground/55">动态项目上下文、用户个性化要求和受保护输出契约。</p></section></div>,
},
]}
/>
<div className="mt-4 flex items-start gap-2 border-t border-border pt-4 text-xs leading-5 text-foreground/55"><Info className="mt-0.5 size-3.5 shrink-0" />每次调整都会创建新版本,启用中的版本不会被原地覆盖。</div>
</aside>
</div>
</Form>
</Drawer>
</AdminPageFrame>