优化脚本
This commit is contained in:
+26
-14
@@ -635,6 +635,14 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProjectAction(projectId: string) {
|
||||
const ok = await action(() => api.deleteProject(projectId), "项目已删除");
|
||||
if (ok !== null && projectId === activeProjectIdRef.current) {
|
||||
setActiveProjectId("");
|
||||
setProjectDetail(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runProductBatch<T>(ids: string[], work: (id: string) => Promise<T>, successText: string): Promise<ProductBatchResult> {
|
||||
const uniqueIds = Array.from(new Set(ids));
|
||||
if (!uniqueIds.length) return { succeededIds: [], failedIds: [] };
|
||||
@@ -804,7 +812,7 @@ export function App() {
|
||||
return null;
|
||||
}
|
||||
await refreshBilling();
|
||||
setNotice({ type: "success", text: okText });
|
||||
if (okText) setNotice({ type: "success", text: okText });
|
||||
return assets[0].id;
|
||||
}
|
||||
|
||||
@@ -889,7 +897,7 @@ export function App() {
|
||||
function renderPage() {
|
||||
switch (page) {
|
||||
case "dashboard":
|
||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} />;
|
||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
|
||||
case "products":
|
||||
return (
|
||||
<ProductsPage
|
||||
@@ -946,14 +954,7 @@ export function App() {
|
||||
initialTab={route.tab}
|
||||
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
|
||||
openPipeline={(projectId) => navigate("pipeline", { projectId })}
|
||||
onDelete={async (projectId) => {
|
||||
const ok = await action(() => api.deleteProject(projectId), "项目已删除");
|
||||
// 删的是当前激活项目就清掉残留 id/详情,否则后续新建/进管线会拿着已删 id 去拉 → 404 / 卡 loading
|
||||
if (ok !== null && projectId === activeProjectIdRef.current) {
|
||||
setActiveProjectId("");
|
||||
setProjectDetail(null);
|
||||
}
|
||||
}}
|
||||
onDelete={deleteProjectAction}
|
||||
/>
|
||||
);
|
||||
case "projectWizard":
|
||||
@@ -1082,7 +1083,7 @@ export function App() {
|
||||
case "settingsNotify":
|
||||
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
|
||||
default:
|
||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} />;
|
||||
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1124,7 @@ export function App() {
|
||||
project={pipelineProject}
|
||||
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
|
||||
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
|
||||
videoModels={modelConfigs.filter((m) => m.capability === "video" && m.status === "active")}
|
||||
loading={loading}
|
||||
navigate={navigate}
|
||||
onBack={() => goBack("projects")}
|
||||
@@ -1149,10 +1151,20 @@ export function App() {
|
||||
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
|
||||
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
|
||||
onGenerateBaseAsset={async (kind, prompt, label, referenceAssetId) => {
|
||||
// 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成
|
||||
// 异步:提交→轮询出图→刷新。角色立绘成功后立刻据该立绘出三视图,用户不必再进详情点一次。
|
||||
// referenceAssetId:角色重跑时传当前立绘 → 后端走 image_edit 参考它,保持人物一致
|
||||
const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }), "基础资产已生成");
|
||||
return assetId ? { adopted_asset: assetId } : null;
|
||||
const assetId = await submitAndPollAsset(
|
||||
() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }),
|
||||
kind === "person" ? "" : "基础资产已生成",
|
||||
);
|
||||
if (!assetId) return null;
|
||||
if (kind === "person") {
|
||||
await submitAndPollAsset(
|
||||
() => api.generateTriview(pipelineProject.id, { portrait_asset_id: assetId }),
|
||||
"角色立绘与三视图已生成",
|
||||
);
|
||||
}
|
||||
return { adopted_asset: assetId };
|
||||
}}
|
||||
onGenerateStoryboard={(prompt) =>
|
||||
// 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」)
|
||||
|
||||
@@ -372,6 +372,9 @@ export const api = {
|
||||
cancelQuickCreate(jobId: string) {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-cancel/${jobId}/`, { method: "POST" });
|
||||
},
|
||||
retryQuickCreate(jobId: string) {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" });
|
||||
},
|
||||
quickCreateHistory() {
|
||||
return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/");
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@ export function TeamModal({ open, title, subtitle = "", icon, close, children, f
|
||||
export function ConfirmModal({ open, title, detail, confirmText, subtitle = "", icon, onCancel, onConfirm, dismissable = true }: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
detail: string;
|
||||
detail: ReactNode;
|
||||
confirmText: string;
|
||||
subtitle?: string;
|
||||
icon?: ReactNode;
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
position: relative;
|
||||
min-height: 152px;
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(0, 1fr) minmax(220px, 1.1fr) 140px;
|
||||
grid-template-columns: 160px minmax(0, 1fr) minmax(220px, 1.1fr) auto;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 18px 34px 18px 32px;
|
||||
@@ -383,6 +383,37 @@
|
||||
.dashboard-page .segment.warn { background: #ff7219; }
|
||||
.dashboard-page .segment.fail { background: var(--accent-crimson); }
|
||||
|
||||
.dashboard-page .project-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.dashboard-page .project-del {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(34, 42, 54, 0.10);
|
||||
border-radius: 10px;
|
||||
color: var(--black-alpha-56);
|
||||
background: var(--control);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: color 180ms ease, background-color 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
.dashboard-page .project-del svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.dashboard-page .project-del:hover {
|
||||
color: var(--accent-crimson);
|
||||
border-color: var(--crimson-bd);
|
||||
background: var(--crimson-bg);
|
||||
}
|
||||
|
||||
.dashboard-page .continue-button {
|
||||
height: 42px;
|
||||
min-width: 88px;
|
||||
@@ -421,8 +452,12 @@
|
||||
grid-template-columns: 122px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
.dashboard-page .stage,
|
||||
.dashboard-page .continue-button { display: none; }
|
||||
.dashboard-page .stage { display: none; }
|
||||
.dashboard-page .project-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.dashboard-page .creation-entry-pair { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -25,7 +25,19 @@ export function publicModelDisplayName(
|
||||
return publicModelRouteName(model.name, model.display_name?.trim() || model.name || fallback);
|
||||
}
|
||||
|
||||
/** Gemini 3.1 只给「提炼提示词」用,脚本助手下拉里不出现。 */
|
||||
/**
|
||||
* 脚本助手只展示可读取商品视觉参考的文案模型。
|
||||
* Gemini 3.1 只给「提炼提示词」用;DeepSeek / DP V4 Pro 当前不接收商品图片,
|
||||
* 因此都不在脚本生成下拉中展示。
|
||||
*/
|
||||
export function isHiddenFromScriptPicker(model: Pick<ModelConfig, "name" | "display_name">) {
|
||||
return model.name === "gemini-3.1-pro-preview" || publicModelDisplayName(model) === "AirShelf Script";
|
||||
const name = (model.name || "").trim().toLowerCase();
|
||||
const label = publicModelDisplayName(model).trim().toLowerCase();
|
||||
return (
|
||||
name === "gemini-3.1-pro-preview"
|
||||
|| label === "airshelf script"
|
||||
|| name.includes("deepseek")
|
||||
|| label.includes("deepseek")
|
||||
|| /\bdp\s*v?4\s*pro\b/i.test(label)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2089,8 +2089,19 @@
|
||||
.rg-kind-person { color: var(--heat); background: var(--heat-8); }
|
||||
.rg-kind-scene { color: var(--black-alpha-56); background: var(--black-alpha-5); }
|
||||
.rg-actions { display: flex; gap: 10px; margin-top: 22px; align-items: stretch; }
|
||||
.rg-actions .btn-primary { flex: 1; }
|
||||
.rg-force { display: inline-flex; align-items: baseline; gap: 5px; }
|
||||
.rg-actions .btn-primary,
|
||||
.rg-actions .rg-force { flex: 1; }
|
||||
.rg-force {
|
||||
height: auto;
|
||||
min-height: 36px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.rg-warn { font-size: 10px; font-family: var(--font-mono); color: var(--black-alpha-48); }
|
||||
|
||||
/* 提取闸门单按钮:醒目主 CTA */
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
.quick-create-page .project-builder-title { display: flex; align-items: center; gap: 14px; }
|
||||
.quick-create-page .project-builder-title h1 { margin: 0 0 5px; color: #17181a; font-size: 28px; line-height: 1.2; font-weight: 700; }
|
||||
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 18px; }
|
||||
.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; margin-right: 5px; padding: 9px 15.6px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; line-height: 18px; }
|
||||
.quick-create-page .project-builder-status strong { color: var(--quick-blue); font-size: 13px; }
|
||||
.quick-create-page .image-back-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; margin-top: 2px; border: 1px solid rgba(0,47,167,.46); border-radius: 12px; color: var(--quick-blue); background: rgba(255,255,255,.82); cursor: pointer; box-shadow: 0 5px 14px rgba(0,47,167,.07); transition: background-color 180ms ease, transform 180ms ease; }
|
||||
.quick-create-page .image-back-button:hover { transform: translateX(-2px); border-color: var(--quick-blue); background: rgba(0,47,167,.055); }.quick-create-page .image-back-button svg { width: 19px; height: 19px; }
|
||||
.quick-create-header { margin-bottom: 16px !important; }
|
||||
@@ -60,7 +58,16 @@
|
||||
.quick-create-page .quick-generating-preview { position: relative; width: 460px; max-width: 100%; height: 258px; display: grid; place-items: center; margin-bottom: 27px; border: 1px solid rgba(0,47,167,.16); border-radius: 15px; background: #edf4ff; box-shadow: 0 18px 34px rgba(0,47,167,.08); }.quick-create-page .quick-generating-preview::before { content: ""; position: absolute; inset: 18px; border-radius: 10px; background: #fff; }
|
||||
.quick-create-page .quick-preview-spinner { position: relative; z-index: 1; width: 62px; height: 62px; border-radius: 50%; background: conic-gradient(from 160deg,transparent 0deg,transparent 136deg,var(--quick-blue) 300deg,rgba(0,47,167,.12) 360deg); -webkit-mask: radial-gradient(circle,transparent 0 66%,#000 69%); mask: radial-gradient(circle,transparent 0 66%,#000 69%); animation: quick-spinner-rotate 1.1s linear infinite; }
|
||||
.quick-create-page .quick-generating-copy { width: 100%; text-align: center; }.quick-create-page .quick-generating-copy h2 { margin: 0 0 8px; font-size: 23px; }.quick-create-page .quick-generating-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; color: #9aa1ab; font-size: 12px; }.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: -1; top: 23px; left: 50%; width: 100%; height: 2px; background: rgba(34,42,54,.12); }.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }.quick-create-page .quick-progress-dot { width: 46px; height: 46px; display: grid; place-items: center; border: 5px solid rgba(0,47,167,.10); border-radius: 50%; color: #fff; background: var(--quick-blue); }.quick-create-page .quick-progress-dot svg { width: 18px; height: 18px; stroke-width: 1.8; }.quick-create-page .quick-progress-node strong { margin-top: 9px; max-width: 8em; text-align: center; font-size: 12px; font-weight: 700; line-height: 1.35; }.quick-create-page .quick-progress-node.done,.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }.quick-create-page .quick-progress-node.active { color: #16171a; }.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: rgba(22,23,26,.12); background: #16171a; }
|
||||
.quick-create-page .quick-progress-track { width: 100%; display: flex; align-items: flex-start; margin-top: 28px; }
|
||||
.quick-create-page .quick-progress-node { position: relative; z-index: 0; flex: 1 1 0; display: grid; justify-items: center; gap: 10px; color: #111216; font-size: 11px; font-weight: 700; }
|
||||
.quick-create-page .quick-progress-node:not(:last-child)::after { content: ""; position: absolute; z-index: 0; top: 19px; left: calc(50% + 24px); width: calc(100% - 48px); height: 2px; background: #111216; }
|
||||
.quick-create-page .quick-progress-dot { position: relative; z-index: 1; width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid #111216; border-radius: 50%; color: #fff; background: #111216; box-shadow: 0 0 0 5px rgba(17,18,22,.05); }
|
||||
.quick-create-page .quick-progress-dot svg { width: 17px; height: 17px; stroke-width: 2; }
|
||||
.quick-create-page .quick-progress-node strong { margin-top: 0; max-width: none; text-align: center; font-size: 11px; font-weight: 700; line-height: 1.35; }
|
||||
.quick-create-page .quick-progress-node.done { color: var(--quick-blue); }
|
||||
.quick-create-page .quick-progress-node.done .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 5px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }
|
||||
.quick-create-page .quick-progress-node.active .quick-progress-dot { box-shadow: 0 0 0 6px rgba(17,18,22,.09); }
|
||||
.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; }
|
||||
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
|
||||
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
|
||||
@@ -97,6 +104,7 @@
|
||||
.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; }
|
||||
.quick-create-page .quick-history-copy { min-width: 0; }
|
||||
.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-badge.is-wait { color: var(--quick-muted); background: rgba(34,42,54,.06); }
|
||||
.quick-create-page .quick-history-copy h3 { margin: 6px 0 4px; font-size: 16px; font-weight: 600; }
|
||||
.quick-create-page .quick-history-copy p { margin: 0; color: var(--quick-muted); font-size: 12px; }
|
||||
.quick-create-page .quick-history-open { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(34,42,54,.12); border-radius: 8px; color: var(--quick-blue); background: #fff; font: inherit; font-size: 13px; cursor: pointer; }
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
FolderKanban,
|
||||
Replace,
|
||||
ScanSearch,
|
||||
Trash2,
|
||||
WandSparkles,
|
||||
} from "lucide-react";
|
||||
import type { BillingSummary, Product, Project } from "../types";
|
||||
import type { NavigateFn, Page } from "./route-config";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
|
||||
type DashTab = "all" | "wip" | "done";
|
||||
type EntryTone = "primary" | "subtle";
|
||||
@@ -100,6 +102,7 @@ export function Dashboard({
|
||||
userName,
|
||||
loading: _loading = false,
|
||||
navigate,
|
||||
onDelete,
|
||||
}: {
|
||||
products: Product[];
|
||||
projects: Project[];
|
||||
@@ -109,8 +112,10 @@ export function Dashboard({
|
||||
userName?: string;
|
||||
loading?: boolean;
|
||||
navigate: NavigateFn;
|
||||
onDelete: (projectId: string) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<DashTab>("all");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const projCount = projectTotal ?? projects.length;
|
||||
const completed = projects.filter((project) => project.status === "completed").length;
|
||||
const running = projects.filter((project) => !["completed", "failed"].includes(project.status)).length;
|
||||
@@ -253,13 +258,24 @@ export function Dashboard({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="continue-button"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
{project.status === "completed" ? "查看" : "继续"}
|
||||
</button>
|
||||
<div className="project-actions">
|
||||
<button
|
||||
className="project-del"
|
||||
type="button"
|
||||
title="删除项目"
|
||||
aria-label={`删除「${project.name}」`}
|
||||
onClick={(event) => { event.stopPropagation(); setDeleteTarget(project); }}
|
||||
>
|
||||
<Trash2 />
|
||||
</button>
|
||||
<button
|
||||
className="continue-button"
|
||||
type="button"
|
||||
onClick={(event) => { event.stopPropagation(); openProject(); }}
|
||||
>
|
||||
{project.status === "completed" ? "查看" : "继续"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -267,6 +283,20 @@ export function Dashboard({
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={Boolean(deleteTarget)}
|
||||
title="删除项目"
|
||||
icon={<Trash2 size={16} />}
|
||||
detail={`确定删除「${deleteTarget?.name || ""}」?将移至「垃圾桶」,可在垃圾桶里恢复或彻底删除。`}
|
||||
confirmText="删除"
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
await onDelete(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -440,20 +440,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
}
|
||||
}, [refs, mode, model, ratio, resolution, duration, seed, doSubmit, notify]);
|
||||
|
||||
const handleRetry = useCallback((task: FreeVideoTask) => {
|
||||
void doSubmit({
|
||||
prompt: task.prompt,
|
||||
mode: task.mode,
|
||||
model: task.model,
|
||||
aspect_ratio: task.aspect_ratio,
|
||||
resolution: task.resolution,
|
||||
duration: task.duration,
|
||||
seed: task.seed,
|
||||
references: task.references
|
||||
});
|
||||
}, [doSubmit]);
|
||||
|
||||
// 再次生成:参数 + 素材 + 提示词(含 mention chip)全部回填输入条
|
||||
// 失败重试 / 再次生成:不立刻再跑同一条任务,把提示词+参数+素材回填到底部操作区,用户改完再点生成
|
||||
const handleReuse = useCallback((task: FreeVideoTask) => {
|
||||
setDetailId(null);
|
||||
setMode(task.mode);
|
||||
@@ -463,7 +450,11 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
setDuration(task.duration);
|
||||
setSeed(task.seed ?? -1);
|
||||
setRefs(task.references.map((r) => ({ ...r, key: nextRefKey() })));
|
||||
window.setTimeout(() => promptRef.current?.setContent(task.prompt, task.references), 0);
|
||||
window.setTimeout(() => {
|
||||
promptRef.current?.setContent(task.prompt, task.references);
|
||||
promptRef.current?.focus();
|
||||
document.querySelector(".fc-composer")?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}, 0);
|
||||
notify("info", "已回填参数,可修改后重新生成");
|
||||
}, [notify]);
|
||||
|
||||
@@ -665,7 +656,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
task={task}
|
||||
progress={progress[task.id] || 3}
|
||||
onOpen={() => setDetailId(task.id)}
|
||||
onRetry={() => handleRetry(task)}
|
||||
onRetry={() => handleReuse(task)}
|
||||
onToggleFavorite={() => handleFavorite(task)}
|
||||
onDelete={() => setDeleteTarget(task)}
|
||||
onDownload={() => void handleDownload(task)}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { isHiddenFromScriptPicker, publicModelDisplayName } from "../model-displ
|
||||
import { isPublicGenerationError, presentGenerationError } from "../generation-error";
|
||||
import type { Notice, Page } from "./route-config";
|
||||
import { stageOrder, statusPill } from "./stage-config";
|
||||
import { MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||
import { ConfirmModal, MediaLightbox, TeamModal, useBodyScrollLock } from "../components/overlays";
|
||||
import { DEFAULT_BILLING_RATES, estimateCost } from "../components/free-create/constants";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import {
|
||||
@@ -517,6 +518,7 @@ export function PipelinePage(props: {
|
||||
onNotify?: (type: "success" | "error", text: string) => void;
|
||||
scriptModelName: string;
|
||||
textModels?: ModelConfig[];
|
||||
videoModels?: ModelConfig[];
|
||||
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
|
||||
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
|
||||
onAddShot: (afterSegmentId: string, content?: { narration?: string; visual_prompt?: string }) => Promise<unknown>;
|
||||
@@ -560,17 +562,22 @@ export function PipelinePage(props: {
|
||||
}) {
|
||||
const {
|
||||
project, loading, navigate, products, assets, onNotify,
|
||||
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
textModels, videoModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||
onGenerateBaseAsset, onAdoptBaseAsset, onSetAdoptState, onDeleteBaseAsset, onAttachBaseAsset, onGenerateModel, onUploadModel, onGenerateTriview, onRenameModel, onGenerateStoryboard, onRerunStoryboardShot, onAdoptStoryboardShotVersion, onPollStoryboardQuiet, onSkipStoryboard,
|
||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onRefreshBilling,
|
||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||
} = props;
|
||||
|
||||
// ── 团队价格系数(差异化调价):页面各处「N 积分/次」文案按团队系数动态显示,拉不到按标准价 1 ──
|
||||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||
const [billingRates, setBillingRates] = useState(DEFAULT_BILLING_RATES);
|
||||
useEffect(() => {
|
||||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||
void api.billingConfig().then((cfg) => setBillingRates({
|
||||
margin: Number(cfg.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin,
|
||||
rate: Number(cfg.points_per_yuan) || DEFAULT_BILLING_RATES.rate,
|
||||
multiplier: Number(cfg.team_price_multiplier) || 1,
|
||||
})).catch(() => undefined);
|
||||
}, []);
|
||||
const priceMultiplier = billingRates.multiplier;
|
||||
// 与后端 apply_team_price 逐字对齐:挂牌整数积分 × 系数 → HALF_UP 最低 1(toFixed(6) 只吸浮点噪声)
|
||||
const pts = (base: number) => (priceMultiplier === 1 ? base : Math.max(1, Math.round(Number((base * priceMultiplier).toFixed(6)))));
|
||||
|
||||
@@ -827,10 +834,13 @@ export function PipelinePage(props: {
|
||||
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise<GenResult> {
|
||||
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
|
||||
addBusy(busyKey);
|
||||
const triKey = kind === "person" ? `${busyKey}:tri` : "";
|
||||
if (triKey) addBusy(triKey);
|
||||
try {
|
||||
return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult;
|
||||
} finally {
|
||||
delBusy(busyKey);
|
||||
if (triKey) delBusy(triKey);
|
||||
}
|
||||
}
|
||||
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
|
||||
@@ -843,7 +853,7 @@ export function PipelinePage(props: {
|
||||
delBusy(busyKey);
|
||||
}
|
||||
}
|
||||
// 流程步骤4 · 生成人物立绘;三视图只在角色详情里手动生成。
|
||||
// 流程步骤4 · 生成人物立绘(App 层自动接力三视图)
|
||||
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
|
||||
return await genBaseAsset("person", prompt, label, busyKey);
|
||||
}
|
||||
@@ -855,16 +865,16 @@ export function PipelinePage(props: {
|
||||
const extractPollRef = useRef(0); // 提取轮询定时器句柄
|
||||
const extractStartedRef = useRef(false); // 是否已有一条轮询在跑(防认领与手点双轮询)
|
||||
type ExtractEntity = { id: string; type: "character" | "scene"; name: string; visual_prompt: string; ref_index: number };
|
||||
// 提取完成后(mode≠only)按返回实体循环出参考图(立绘/场景图;三视图手动生成)
|
||||
// 提取完成后(mode≠only)按返回实体循环出参考图(角色=立绘+三视图,场景=场景图)
|
||||
async function runGenForEntities(entities: ExtractEntity[], mode: "gen" | "full") {
|
||||
setExtractMsg("已认出角色 / 场景,正在生成参考图…");
|
||||
for (const e of entities) {
|
||||
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
|
||||
if (e.type === "character") {
|
||||
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘;三视图手动生成
|
||||
else await genBaseAsset("person", e.visual_prompt, e.name, bk); // 立绘
|
||||
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk);
|
||||
else await genBaseAsset("person", e.visual_prompt, e.name, bk);
|
||||
} else {
|
||||
await genBaseAsset("scene", e.visual_prompt, e.name, bk); // 场景图
|
||||
await genBaseAsset("scene", e.visual_prompt, e.name, bk);
|
||||
}
|
||||
}
|
||||
await onRefreshProject();
|
||||
@@ -1121,6 +1131,27 @@ export function PipelinePage(props: {
|
||||
const segments = [...(project.video_segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const segDone = segments.filter((s) => ["succeeded", "completed", "done"].includes(s.status)).length;
|
||||
const segTotalSec = segments.reduce((sum, s) => sum + (s.target_duration_seconds || 0), 0);
|
||||
const videoAnyStarted = segments.some((s) => ["running", "succeeded", "queued", "completed", "done"].includes(s.status));
|
||||
const [chargeConfirm, setChargeConfirm] = useState<"storyboard" | "video" | null>(null);
|
||||
const sbChargeShots = shots.length || sbExpectedShots;
|
||||
const sbChargePoints = sbChargeShots * pts(20);
|
||||
const defaultVideoModel = (videoModels ?? []).find((m) => m.status === "active") || (videoModels ?? [])[0];
|
||||
const videoChargeDurations = segments.length
|
||||
? segments.map((s) => s.target_duration_seconds || 15)
|
||||
: shots.map((s) => shotSeconds(s));
|
||||
const videoChargeShots = videoChargeDurations.length;
|
||||
const videoChargePoints = videoChargeDurations.reduce(
|
||||
(sum, duration) => sum + estimateCost(defaultVideoModel, { ratio: "9:16", resolution: "720p", duration, refs: [] }, billingRates).points,
|
||||
0,
|
||||
);
|
||||
const sbNextLabel = sbAnyImage || sbAnyGenerating
|
||||
? "进入故事板"
|
||||
: `生成故事板 · ${sbChargeShots > 0 ? `${sbChargePoints} 积分` : `${pts(20)} 积分/镜`}`;
|
||||
const videoNextLabel = videoAnyStarted
|
||||
? "进入视频"
|
||||
: videoChargePoints > 0
|
||||
? `生成视频 · ${videoChargePoints} 积分`
|
||||
: "生成视频";
|
||||
// Stage 4 · 视频详情弹窗:选中段 + 查看的版本 + 可编辑的重跑提示词
|
||||
const [vdSegId, setVdSegId] = useState<string | null>(null);
|
||||
const [vdVerId, setVdVerId] = useState<string | null>(null);
|
||||
@@ -1897,6 +1928,10 @@ export function PipelinePage(props: {
|
||||
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
|
||||
const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
|
||||
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
|
||||
const startStoryboardGeneration = () => {
|
||||
setSbGenerating(true);
|
||||
void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false));
|
||||
};
|
||||
const videoPrompt = "竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感";
|
||||
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
|
||||
|
||||
@@ -3649,8 +3684,19 @@ export function PipelinePage(props: {
|
||||
<span><Info />确认后将使用以上资产创建故事板,后续仍可替换。提取 {pts(10)} / 人物 {pts(20)} / 场景 {pts(20)} · 失败不扣</span>
|
||||
<div>
|
||||
<button className="pl-ghost" type="button" onClick={() => goStage(1)}><ArrowLeft /><span>返回脚本</span></button>
|
||||
<button className="pl-next" type="button" onClick={() => guardGen(shots, () => goStage(3))}>
|
||||
<span>确认资产,进入故事板</span>
|
||||
<button
|
||||
className="pl-next"
|
||||
type="button"
|
||||
disabled={sbGenerating && !sbAnyImage && !sbAnyGenerating}
|
||||
onClick={() => {
|
||||
if (sbAnyImage || sbAnyGenerating) {
|
||||
goStage(3);
|
||||
return;
|
||||
}
|
||||
guardGen(shots, () => setChargeConfirm("storyboard"));
|
||||
}}
|
||||
>
|
||||
<span>{sbNextLabel}</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
@@ -3757,10 +3803,6 @@ export function PipelinePage(props: {
|
||||
onChange={(v) => setStoryboardPrompt(v.trim())}
|
||||
/>
|
||||
<div className="sb-stage-actions">
|
||||
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={sbAnyGenerating} onClick={() => guardGen(shots, () => { setSbGenerating(true); void Promise.resolve(onGenerateStoryboard(storyboardPrompt || SB_PROMPT_DEFAULT)).finally(() => setSbGenerating(false)); })}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
|
||||
{sbAnyImage ? "全部重跑" : "开始生成故事板"}
|
||||
</button>
|
||||
{sbActiveShot && (
|
||||
<button className="btn btn-sm" type="button" disabled={activeBusy} title="只重出当前这一场(新增一条该场历史版本)" onClick={() => guardGen(shots.filter((s) => s.sort_order === sbActiveShot.sort_order), () => rerunStoryboardShotOptimistic(sbActiveShot.id))}>
|
||||
{activeBusy ? <><span className="spinner btn-spin" aria-hidden="true" />生成中…</> : `↻ 重跑本场`}
|
||||
@@ -3819,10 +3861,25 @@ export function PipelinePage(props: {
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
|
||||
故事板还没出齐
|
||||
</span>
|
||||
<span className="pop-body">请先点上方 <b>开始生成故事板</b>,每场都出片后再确认进入视频生成。</span>
|
||||
<span className="pop-body">请先等每场故事板都出片,再点 <b>生成视频</b>。</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary btn-lg" type="button" disabled={loading} onClick={() => { if (!sbAllDone) { setSbConfirmHint(true); return; } goStage(4); }}>确认故事板,开始生成视频 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (!sbAllDone) { setSbConfirmHint(true); return; }
|
||||
if (videoAnyStarted) {
|
||||
goStage(4);
|
||||
return;
|
||||
}
|
||||
guardVideoGen(shots, null, () => setChargeConfirm("video"));
|
||||
}}
|
||||
>
|
||||
{videoNextLabel}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3832,7 +3889,6 @@ export function PipelinePage(props: {
|
||||
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
|
||||
{viewStage === 4 && (() => {
|
||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
|
||||
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
|
||||
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
|
||||
const segMax = segSeconds.length ? Math.max(...segSeconds) : 0;
|
||||
@@ -3864,7 +3920,6 @@ export function PipelinePage(props: {
|
||||
</div>
|
||||
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
||||
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" />提交中…</> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
|
||||
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
|
||||
<button className="btn btn-sm" type="button" disabled={exporting || segDone === 0} title={exportErr || "把所有已完成视频片段打包下载"} onClick={() => void exportAllVideos()}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M7 10l5 5 5-5" /><path d="M12 15V3" /></svg>
|
||||
@@ -4460,7 +4515,10 @@ export function PipelinePage(props: {
|
||||
)}
|
||||
<div className="rg-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={() => { setRefGate(null); goStage(2); }}>去基础资产页补齐</button>
|
||||
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>仍要继续 <span className="rg-warn">可能跟角色对不上</span></button>
|
||||
<button type="button" className="btn btn-ghost rg-force" onClick={() => { const go = refGate.proceed; setRefGate(null); go(); }}>
|
||||
<span>仍要继续</span>
|
||||
<span className="rg-warn">可能跟角色对不上</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -4549,7 +4607,7 @@ export function PipelinePage(props: {
|
||||
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
|
||||
async function regenPortrait() {
|
||||
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
|
||||
// 重跑立绘 → 只追加新立绘候选并采用;三视图改手动(用「生成三视图」按钮),不再链式自动出。
|
||||
// 重跑立绘 → 追加新立绘并自动接力该版三视图。
|
||||
// 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。
|
||||
const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined;
|
||||
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref);
|
||||
@@ -4788,6 +4846,42 @@ export function PipelinePage(props: {
|
||||
</p>
|
||||
</div>
|
||||
</TeamModal>
|
||||
<ConfirmModal
|
||||
open={chargeConfirm !== null}
|
||||
title={chargeConfirm === "video" ? "确认生成视频" : "确认生成故事板"}
|
||||
subtitle="// 失败不扣 · 成功后结算"
|
||||
icon={<Sparkles size={16} />}
|
||||
detail={chargeConfirm === "video"
|
||||
? (
|
||||
<>
|
||||
将按故事板生成 <b>{videoChargeShots || "多"} 段</b>视频,预估扣除 <b>{videoChargePoints > 0 ? `${videoChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{videoChargeShots > 0 && videoChargePoints > 0 ? `(约 ${Math.round(videoChargePoints / videoChargeShots)} 积分/段)` : ""}。
|
||||
确认后进入视频页并开始生成。
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
将按脚本生成 <b>{sbChargeShots || "多"} 场</b>分镜图,预估扣除 <b>{sbChargePoints > 0 ? `${sbChargePoints} 积分` : "按实际用量结算"}</b>
|
||||
{sbChargeShots > 0 ? `(${pts(20)} 积分/镜 × ${sbChargeShots} 场)` : ""}。
|
||||
确认后进入故事板并开始生成。
|
||||
</>
|
||||
)}
|
||||
confirmText={chargeConfirm === "video"
|
||||
? (videoChargePoints > 0 ? `确认生成 · ${videoChargePoints} 积分` : "确认生成")
|
||||
: (sbChargePoints > 0 ? `确认生成 · ${sbChargePoints} 积分` : "确认生成")}
|
||||
onCancel={() => setChargeConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const kind = chargeConfirm;
|
||||
setChargeConfirm(null);
|
||||
if (kind === "storyboard") {
|
||||
goStage(3);
|
||||
startStoryboardGeneration();
|
||||
} else if (kind === "video") {
|
||||
goStage(4);
|
||||
submitAllVideosOptimistic();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Boxes,
|
||||
Clapperboard,
|
||||
Download,
|
||||
ImagePlus,
|
||||
LayoutPanelTop,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Play,
|
||||
Upload,
|
||||
UsersRound,
|
||||
WandSparkles,
|
||||
X,
|
||||
Columns2,
|
||||
@@ -31,10 +31,10 @@ import type { NavigateFn } from "./route-config";
|
||||
|
||||
const QUICK_JOB_KEY = "airshelf:quick-create-job";
|
||||
const PROGRESS_STEPS = [
|
||||
{ label: "识别商品与卖点", icon: ScanSearch },
|
||||
{ label: "推荐脚本方向", icon: ScrollText },
|
||||
{ label: "匹配模特与场景", icon: UsersRound },
|
||||
{ label: "生成故事板与视频", icon: Clapperboard },
|
||||
{ label: "脚本", icon: ScrollText },
|
||||
{ label: "资产", icon: Boxes },
|
||||
{ label: "故事板", icon: LayoutPanelTop },
|
||||
{ label: "视频", icon: Clapperboard },
|
||||
];
|
||||
const QUICK_RATIOS = [
|
||||
{ value: "9:16", label: "9:16 竖屏" },
|
||||
@@ -70,6 +70,14 @@ function historyTitle(item: QuickCreateJob) {
|
||||
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
|
||||
}
|
||||
|
||||
function historyBadge(item: QuickCreateJob) {
|
||||
if (item.status === "cancelled") return "已取消";
|
||||
if (item.status === "succeeded" || item.result?.video_url || item.result?.video_segments?.some((clip) => clip.video_url)) {
|
||||
return "已完成";
|
||||
}
|
||||
return "未完成";
|
||||
}
|
||||
|
||||
function savedJobId() {
|
||||
try {
|
||||
return localStorage.getItem(QUICK_JOB_KEY) || "";
|
||||
@@ -298,6 +306,33 @@ export function QuickCreatePage({
|
||||
setImages([]);
|
||||
}
|
||||
|
||||
async function retryGeneration() {
|
||||
if (job?.id && job.status === "failed") {
|
||||
setSubmitting(true);
|
||||
setServiceUnavailable(false);
|
||||
setUnavailableMessage("");
|
||||
cancelRequestedRef.current = false;
|
||||
try {
|
||||
const next = await api.retryQuickCreate(job.id);
|
||||
setJob(next);
|
||||
setJobId(next.id);
|
||||
try {
|
||||
localStorage.setItem(QUICK_JOB_KEY, next.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
onNotify?.("success", "已从上次进度继续生成");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error ? error.message : "继续生成失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await startGeneration();
|
||||
}
|
||||
|
||||
async function startGeneration() {
|
||||
if (!name.trim() || (!images.length && !savedImages.length)) {
|
||||
onNotify?.("info", "请先填写商品名称并上传商品图片");
|
||||
@@ -401,11 +436,11 @@ export function QuickCreatePage({
|
||||
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
|
||||
const isComplete = job?.status === "succeeded";
|
||||
const isCancelled = job?.status === "cancelled";
|
||||
const isFailed = job?.status === "failed" || isCancelled || serviceUnavailable;
|
||||
const isFailed = !isGenerating && (job?.status === "failed" || isCancelled || serviceUnavailable);
|
||||
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
|
||||
const imageCount = savedImages.length + images.length;
|
||||
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(3, job?.phase_index ?? 0));
|
||||
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
|
||||
const result = job?.result;
|
||||
const videoClips = result?.video_segments?.length
|
||||
? result.video_segments
|
||||
@@ -434,7 +469,6 @@ export function QuickCreatePage({
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label={backLabel}><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1></div>
|
||||
</div>
|
||||
<div className="project-builder-status"><strong>AI 自动编排</strong><span>· 无需逐步配置</span></div>
|
||||
</header>
|
||||
|
||||
<div className={shellClass} id="quickCreateShell">
|
||||
@@ -534,7 +568,7 @@ export function QuickCreatePage({
|
||||
{PROGRESS_STEPS.map((step, index) => {
|
||||
const Icon = step.icon;
|
||||
const done = index < activePhase;
|
||||
const active = isGenerating && index === activePhase;
|
||||
const active = isGenerating && index === activePhase && activePhase < 4;
|
||||
return <div key={step.label} className={`quick-progress-node${active ? " active" : ""}${done ? " done" : ""}`}><span className="quick-progress-dot"><Icon /></span><strong>{step.label}</strong></div>;
|
||||
})}
|
||||
</div>
|
||||
@@ -575,10 +609,10 @@ export function QuickCreatePage({
|
||||
<div className="quick-state quick-state-failed">
|
||||
<span className="quick-failed-icon"><AlertCircle /></span>
|
||||
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}</h2>
|
||||
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成过程中遇到问题,可以重试或重新开始。"}</p>
|
||||
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的故事板不会重做。"}</p>
|
||||
<div className="quick-failed-actions">
|
||||
{canRetry && !serviceUnavailable ? (
|
||||
<button type="button" className="primary-action" onClick={() => void startGeneration()}><RefreshCw />重试</button>
|
||||
<button type="button" className="primary-action" onClick={() => void retryGeneration()}><RefreshCw />重试</button>
|
||||
) : null}
|
||||
<button type="button" className={canRetry && !serviceUnavailable ? "secondary-action" : "primary-action"} onClick={resetResult}>
|
||||
<RefreshCw />重新开始
|
||||
@@ -605,6 +639,7 @@ export function QuickCreatePage({
|
||||
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
|
||||
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
|
||||
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
|
||||
const badge = historyBadge(item);
|
||||
return (
|
||||
<article key={item.id} className="quick-history-card">
|
||||
<button
|
||||
@@ -617,7 +652,7 @@ export function QuickCreatePage({
|
||||
<small>{formatClock(duration)}</small>
|
||||
</button>
|
||||
<div className="quick-history-copy">
|
||||
<span className="quick-history-badge">已完成</span>
|
||||
<span className={`quick-history-badge${badge === "已完成" ? "" : " is-wait"}`}>{badge}</span>
|
||||
<h3>{historyTitle(item)}</h3>
|
||||
<p>极速成片 · {scenes}场 · {item.result?.aspect_ratio || item.settings?.aspect_ratio || "9:16"} · {(item.result?.resolution || item.settings?.resolution || "720p").toUpperCase()} · {item.result?.video_model || item.settings?.video_model_label || "智能模型"}</p>
|
||||
</div>
|
||||
|
||||
@@ -1053,9 +1053,11 @@
|
||||
|
||||
.vr-page .remix-history-prompt textarea {
|
||||
width: 100%;
|
||||
min-height: 108px;
|
||||
min-height: 360px;
|
||||
max-height: 560px;
|
||||
display: block;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.12);
|
||||
border-radius: 9px;
|
||||
|
||||
Reference in New Issue
Block a user