添加全能创作功能

This commit is contained in:
Azmat@qq.com
2026-09-03 13:11:46 +08:00
parent 22ed2833ad
commit 6a628b0ca7
53 changed files with 9115 additions and 108 deletions
+7 -7
View File
@@ -42,12 +42,12 @@ import { productMockCoverUrl } from "./products";
import { isLocalLife } from "../product-business";
import "../ai-tools-page.css";
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/图片创作,
// 工作台生成模式 → 中文标签(优先用它给任务卡命名:能区分模特上身图/平台套图/自由创作,
// 而 task_type 区分不了——cover 与 image 都是 product_image)
const MODE_LABEL: Record<string, string> = {
model: "模特上身图",
cover: "平台套图",
image: "图片创作"
image: "自由创作"
};
const MODE_TAG: Record<string, string> = {
model: "模特上身",
@@ -142,19 +142,19 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
page: "modelPhoto" as Page,
title: "模特上身",
desc: "上传商品和模特参考图,生成自然统一的服装、饰品上身效果。",
image: "/assets/yz/tool-model.jpg",
image: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg",
},
{
page: "platformCover" as Page,
title: "平台套图",
desc: "基于商品主图一次生成主图、卖点图、细节图与场景图。",
image: "/assets/yz/tool-cover.jpg",
image: "/assets/prototype/unbranded-running-shoe-remix.png",
},
{
page: "imageOptimize" as Page,
title: "图片创作",
title: "自由创作",
desc: "使用提示词、参考图和画布比例自由生成或修改视觉素材。",
image: "/assets/yz/tool-studio.jpg",
image: "/assets/prototype/photo-1557682250-33bd709cbe85.jpg",
}
];
@@ -441,7 +441,7 @@ const MODE_META: Record<
}
> = {
image: {
title: "图片创作",
title: "自由创作",
tag: "[ IMAGE · STUDIO ]",
desc: "使用提示词与参考图,自由生成或修改电商视觉素材",
ratio: "1:1",
+2
View File
@@ -15,3 +15,5 @@ export { QuickCreatePage } from "./quick-create";
export { VideoRemixPage } from "./video-remix";
export { VideoReplacePage } from "./video-replace";
export { SettingsPage } from "./settings";
export { OmniCreatePage, OmniHistoryPage } from "./omni-create";
export { OmniSessionPage } from "./omni-session";
+766
View File
@@ -0,0 +1,766 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Box,
ChevronDown,
ChevronRight,
FolderOpen,
History,
Image as ImageIcon,
Play,
Plus,
SlidersHorizontal,
Sparkles,
Trash2,
Upload,
UserRound,
Users,
Video,
WandSparkles,
X,
} from "lucide-react";
import { api } from "../api";
import { CustomSelect } from "../components/custom-select";
import { ConfirmModal } from "../components/overlays";
import type { CreationConversation, CreationRef } from "../types";
import type { NavigateFn } from "./route-config";
type OutputMode = "video" | "image";
type PresetItem = {
name: string;
category: string;
mode: OutputMode;
title: string;
desc: string;
starter: string;
cover: string;
};
type Attachment = { name: string; type: string; url?: string; source: "local" | "library" };
const VIDEO_PRESETS: PresetItem[] = [
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" },
{ name: "商品图一键成片", category: "commerce", mode: "video", title: "商品图一键成片", desc: "从商品参考图建立镜头语言,自动补齐场景、动作与转场。", starter: "根据我上传的商品图片生成一条节奏清晰的电商短视频。", cover: "/assets/prototype/photo-1556228578-8c89e6adf883.jpg" },
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/prototype/photo-1483985988355-763728e1935b.jpg" },
{ name: "点触换款", category: "visual", mode: "video", title: "点触换款", desc: "用统一构图和动作触发商品变化,快速展示多个款式或 SKU。", starter: "创作一条通过点击动作连续切换不同商品款式的短视频。", cover: "/assets/prototype/unbranded-running-shoe-remix.png" },
{ name: "探店漫游", category: "story", mode: "video", title: "探店漫游", desc: "以空间动线串联门店环境、服务细节与主推项目。", starter: "根据门店素材创作一条有路线感和空间氛围的探店漫游视频。", cover: "/assets/prototype/photo-1497366754035-f200968a6e72.jpg" },
{ name: "品牌质感大片", category: "visual", mode: "video", title: "品牌质感大片", desc: "通过统一的光影、材质与镜头节奏建立更强的品牌识别。", starter: "创作一条强调光影、材质和品牌气质的高级感商品短片。", cover: "/assets/prototype/video-free-cinematic.png" },
];
const IMAGE_PRESETS: PresetItem[] = [
{ name: "商品场景套图", category: "product", mode: "image", title: "商品场景套图", desc: "一次生成多张统一视觉的电商图片,覆盖主图、场景和细节表达。", starter: "根据商品参考图生成一组统一风格的主图、场景图和细节图。", cover: "/assets/prototype/photo-1556229010-6c3f2c9ca5f8.jpg" },
{ name: "极简棚拍", category: "product", mode: "image", title: "极简棚拍", desc: "干净背景、柔和投影与明确主体,适合商品主图和详情页头图。", starter: "生成一组留白克制、光线干净的极简棚拍商品图。", cover: "/assets/prototype/photo-1523275335684-37898b6baf30.jpg" },
{ name: "清透自然光人像", category: "portrait", mode: "image", title: "清透自然光人像", desc: "保留真实肤质与自然光影,适合人物种草和生活方式内容。", starter: "生成一组清透自然光、肤色真实的人物氛围图。", cover: "/assets/prototype/photo-1524504388940-b1c1722653e1.jpg" },
{ name: "生活方式场景", category: "scene", mode: "image", title: "生活方式场景", desc: "将商品融入真实空间和使用动作,强调自然、可信的生活气息。", starter: "把商品放入真实、舒适的生活方式场景中,生成自然使用感的图片。", cover: "/assets/prototype/photo-1600210492486-724fe5c67fb0.jpg" },
{ name: "高级奢华质感", category: "style", mode: "image", title: "高级奢华质感", desc: "通过深色环境、局部高光和材质细节强化品牌高级感。", starter: "生成一组深色光影、精致材质与高级氛围的品牌视觉图片。", cover: "/assets/prototype/video-quick-cinematic-v2.png" },
{ name: "复古胶片风格", category: "style", mode: "image", title: "复古胶片风格", desc: "使用低饱和色彩、胶片颗粒和柔和对比形成怀旧情绪。", starter: "生成一组低饱和、细腻颗粒与复古色调的胶片感图片。", cover: "/assets/prototype/photo-1485846234645-a62644f84728.jpg" },
];
const VIDEO_FILTERS = [
{ key: "all", label: "全部" },
{ key: "story", label: "剧情" },
{ key: "commerce", label: "电商" },
{ key: "speaker", label: "口播" },
{ key: "visual", label: "视觉" },
];
const IMAGE_FILTERS = [
{ key: "all", label: "全部" },
{ key: "product", label: "商品" },
{ key: "portrait", label: "人像" },
{ key: "scene", label: "场景" },
{ key: "style", label: "风格" },
];
const VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"];
const IMAGE_MODELS = ["Seedream5.0", "YQ image2"];
const VIDEO_DURATIONS = ["4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒"];
const IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
const RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
const MENTION_REF_LIMIT = 5;
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
{ type: "asset", label: "素材", Icon: ImageIcon },
{ type: "character", label: "角色", Icon: UserRound },
{ type: "model", label: "模特", Icon: Users },
{ type: "product", label: "商品", Icon: Box },
{ type: "scene", label: "场景", Icon: FolderOpen },
];
function formatRelativeTime(iso: string) {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "";
const minutes = Math.floor((Date.now() - then) / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN");
}
function toOptions(values: string[]) {
return values.map((value) => ({ value, label: value }));
}
export function OmniCreatePage({
navigate,
onNotify,
}: {
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) {
const [outputMode, setOutputMode] = useState<OutputMode>("video");
const [selectedCase, setSelectedCase] = useState<PresetItem | null>(null);
const [prompt, setPrompt] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [model, setModel] = useState("Seedance 2.5");
const [resolution, setResolution] = useState("1080p");
const [ratio, setRatio] = useState("9:16");
const [duration, setDuration] = useState("智能时长");
const [customDurationOn, setCustomDurationOn] = useState(false);
const [durationMenuOpen, setDurationMenuOpen] = useState(false);
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
const [mentionMenuOpen, setMentionMenuOpen] = useState(false);
const [mentionTab, setMentionTab] = useState<CreationRef["type"]>("asset");
const [mentionLoading, setMentionLoading] = useState(false);
const [mentionResults, setMentionResults] = useState<CreationRef[]>([]);
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
const [previewCase, setPreviewCase] = useState<PresetItem | null>(null);
const [activeCategory, setActiveCategory] = useState("all");
const fileInputRef = useRef<HTMLInputElement>(null);
const toolsRef = useRef<HTMLDivElement>(null);
const [starting, setStarting] = useState(false);
useEffect(() => {
if (outputMode === "image") {
setModel("Seedream5.0");
setResolution("模型默认");
setRatio("1:1");
setDuration("1 张");
setCustomDurationOn(true);
} else {
setModel("Seedance 2.5");
setResolution("1080p");
setRatio("9:16");
setDuration("智能时长");
setCustomDurationOn(false);
}
setActiveCategory("all");
setDurationMenuOpen(false);
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
}, [outputMode]);
useEffect(() => {
const onDown = (event: MouseEvent) => {
const target = event.target as Node;
if (toolsRef.current?.contains(target)) return;
setUploadMenuOpen(false);
setMentionMenuOpen(false);
setDurationMenuOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
const filters = outputMode === "video" ? VIDEO_FILTERS : IMAGE_FILTERS;
const presets = useMemo(() => {
const list = outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
if (activeCategory === "all") return list;
return list.filter((item) => item.category === activeCategory);
}, [outputMode, activeCategory]);
const applyPreset = (preset: PresetItem) => {
setSelectedCase(preset);
setPrompt(preset.starter);
setPreviewCase(null);
onNotify?.("info", `已选择预设:${preset.name}`);
};
const openMentions = async (query = "", type: CreationRef["type"] = mentionTab) => {
setMentionTab(type);
setMentionMenuOpen(true);
setUploadMenuOpen(false);
setDurationMenuOpen(false);
setMentionLoading(true);
setMentionResults([]);
try {
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
setMentionResults(res.results);
setTypeLabels(res.type_labels);
} catch (error) {
onNotify?.("error", (error as Error).message);
} finally {
setMentionLoading(false);
}
};
const insertMention = (ref: CreationRef) => {
if (pendingRefs.some((r) => r.id === ref.id)) {
setMentionMenuOpen(false);
return;
}
if (pendingRefs.length >= MENTION_REF_LIMIT) {
onNotify?.("info", `最多引用 ${MENTION_REF_LIMIT}`);
setMentionMenuOpen(false);
return;
}
setPendingRefs((prev) => [...prev, ref]);
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
setMentionMenuOpen(false);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (!files.length) return;
setAttachments((prev) => [
...prev,
...files.map((file) => ({
name: file.name,
type: file.type,
url: URL.createObjectURL(file),
source: "local" as const,
})),
]);
setUploadMenuOpen(false);
event.target.value = "";
};
return (
<section className="page-view omni-create-page omni-home-page">
<div className="omni-home-shell">
<header className="omni-home-hero">
<button type="button" className="omni-home-history" onClick={() => navigate("omniHistory")}>
<History />
</button>
<span className="omni-home-kicker">
<Sparkles /> YINGQING CREATIVE AGENT
</span>
<h1></h1>
<p></p>
<div className="omni-output-switch" aria-label="输出类型">
<button type="button" className={outputMode === "video" ? "active" : ""} onClick={() => setOutputMode("video")}></button>
<button type="button" className={outputMode === "image" ? "active" : ""} onClick={() => setOutputMode("image")}></button>
</div>
</header>
<section className="omni-start-composer" aria-label="全能创作输入区">
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.name}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
<X />
</button>
</span>
))}{attachments.map((file, index) => (
<span className="omni-attachment-chip" key={`${file.name}-${index}`}>
{file.type.startsWith("video") ? <Video /> : <ImageIcon />}
<span>{file.name}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${file.name}`}
onClick={() => setAttachments((prev) => prev.filter((_, i) => i !== index))}
>
<X />
</button>
</span>
))}</div>
<textarea
id="omniStartPrompt"
rows={3}
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景或已有素材……"
value={prompt}
onChange={(event) => {
const value = event.target.value;
setPrompt(value);
const caret = event.target.selectionStart ?? 0;
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
}}
/>
<div className="omni-start-toolbar">
<div className="omni-start-tools" ref={toolsRef}>
<div className="omni-upload-wrap">
<button
type="button"
className="omni-icon-tool"
aria-label="添加参考素材"
onClick={() => {
setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false);
setDurationMenuOpen(false);
}}
>
<Plus />
</button>
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
<strong></strong>
<button
type="button"
onClick={() => {
setUploadMenuOpen(false);
void openMentions();
}}
>
<FolderOpen />
<span><small>使</small></span>
</button>
<button type="button" onClick={() => fileInputRef.current?.click()}>
<Upload />
<span><small></small></span>
</button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={handleFileChange}
/>
<div className="omni-mention-wrap">
<button
type="button"
className="omni-icon-tool"
aria-label="引用素材"
onClick={() => {
if (mentionMenuOpen) setMentionMenuOpen(false);
else void openMentions("", mentionTab);
setUploadMenuOpen(false);
setDurationMenuOpen(false);
}}
>
@
</button>
<div className="omni-session-mention-menu" hidden={!mentionMenuOpen} role="dialog" aria-label="引用素材">
<div className="omni-at-cats" role="tablist">
{MENTION_TABS.map((tab) => (
<button
type="button"
key={tab.type}
role="tab"
aria-selected={mentionTab === tab.type}
className={mentionTab === tab.type ? "is-on" : ""}
onClick={() => { if (mentionTab !== tab.type) void openMentions("", tab.type); }}
>
<tab.Icon size={14} />
{tab.label}
</button>
))}
</div>
<div className="omni-at-list">
{mentionLoading ? (
<div className="omni-at-loading" aria-live="polite">
<span className="omni-send-spinner" />
</div>
) : mentionResults.length === 0 ? (
<strong></strong>
) : (
mentionResults.map((ref) => (
<button type="button" key={ref.id} onClick={() => insertMention(ref)}>
{ref.cover ? <img src={ref.cover} alt="" /> : <Play />}
<span>
{ref.name.split(" · ")[0]}
<small>{typeLabels[ref.type] || MENTION_TABS.find((tab) => tab.type === ref.type)?.label}</small>
</span>
</button>
))
)}
</div>
</div>
</div>
<label className="omni-parameter omni-parameter-model">
<CustomSelect
fill
size="sm"
aria-label={outputMode === "video" ? "视频模型" : "图片模型"}
value={model}
onChange={setModel}
options={toOptions(outputMode === "video" ? VIDEO_MODELS : IMAGE_MODELS)}
/>
</label>
<label className={`omni-parameter${outputMode === "image" ? " is-hidden" : ""}`}>
<CustomSelect
fill
size="sm"
aria-label="分辨率"
value={resolution}
onChange={setResolution}
options={toOptions(["1080p", "720p", "480p"])}
/>
</label>
<label className="omni-parameter">
<CustomSelect
fill
size="sm"
aria-label="画面比例"
value={ratio}
onChange={setRatio}
options={toOptions(RATIOS)}
/>
</label>
<div className={`omni-duration-control${outputMode === "image" ? " is-image-count" : ""}`}>
<button
type="button"
className="omni-duration-trigger"
aria-expanded={durationMenuOpen}
onClick={() => {
setDurationMenuOpen((open) => !open);
setUploadMenuOpen(false);
setMentionMenuOpen(false);
}}
>
<span>{duration}</span>
<ChevronDown />
</button>
<div className="omni-duration-menu" hidden={!durationMenuOpen}>
<span className="omni-duration-title">{outputMode === "image" ? "生成张数" : "时长"}</span>
<div className="omni-duration-modes">
<button
type="button"
className={!customDurationOn ? "active" : ""}
onClick={() => {
setCustomDurationOn(false);
setDuration("智能时长");
setDurationMenuOpen(false);
}}
>
<Sparkles />
<span></span>
</button>
<button
type="button"
className={customDurationOn ? "active" : ""}
onClick={() => setCustomDurationOn(true)}
>
<SlidersHorizontal />
<span></span>
</button>
</div>
<div className="omni-duration-values" hidden={outputMode === "video" && !customDurationOn}>
{(outputMode === "image" ? IMAGE_COUNTS : VIDEO_DURATIONS).map((value) => (
<button
type="button"
key={value}
className={duration === value ? "active" : ""}
onClick={() => {
setDuration(value);
setCustomDurationOn(true);
setDurationMenuOpen(false);
}}
>
{value}
</button>
))}
</div>
</div>
</div>
</div>
<button
type="button"
className="omni-start-generate"
disabled={starting}
onClick={() => {
const text = prompt.trim();
if (!text && !selectedCase && attachments.length === 0) {
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
return;
}
if (starting) return;
setStarting(true);
// 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0)
void api
.createCreation({
title: (text || selectedCase?.title || "未命名创作").slice(0, 20),
mode: outputMode,
preset: selectedCase?.name || "",
params: {
model,
ratio,
...(outputMode === "video"
? { resolution, duration }
: { count: duration }),
},
})
.then((conversation) => {
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs });
})
.catch((error) => {
onNotify?.("error", (error as Error).message);
setStarting(false);
});
}}
>
{starting ? "正在创建…" : "开始创作"}
</button>
</div>
</section>
<section className="omni-case-library" aria-labelledby="omniCaseTitle">
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
</div>
</div>
<div className="omni-case-grid">
{presets.map((card) => (
<button
type="button"
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
data-mode={card.mode}
key={card.name}
onClick={() => setPreviewCase(card)}
>
<span className="omni-case-visual">
<img src={card.cover} alt="" />
<span className="omni-case-play">
{card.mode === "video" ? <Play className="lucide-play" /> : <ImageIcon />}
</span>
</span>
<span className="omni-case-copy">
<strong>{card.title}</strong>
<small>{card.desc}</small>
</span>
<span
className="omni-card-use"
onClick={(event) => {
event.stopPropagation();
applyPreset(card);
}}
>
使
</span>
</button>
))}
</div>
</section>
<div className="omni-preset-modal" hidden={!previewCase}>
<button type="button" className="omni-preset-scrim" aria-label="关闭预设详情" onClick={() => setPreviewCase(null)} />
{previewCase && (
<section className="omni-preset-dialog" role="dialog" aria-modal="true" aria-labelledby="omniPresetDialogTitle">
<button type="button" className="omni-preset-close" aria-label="关闭" onClick={() => setPreviewCase(null)}>
<X />
</button>
<div className="omni-preset-media-frame">
<img src={previewCase.cover} alt="预设示例" />
{previewCase.mode === "video" && (
<span className="omni-preset-video-mark">
<Play />
</span>
)}
</div>
<div className="omni-preset-detail">
<span className="omni-preset-kind">{previewCase.mode === "video" ? "视频预设" : "图片预设"}</span>
<h2 id="omniPresetDialogTitle">{previewCase.title}</h2>
<p>{previewCase.desc}</p>
<div className="omni-preset-default">
<span></span>
<p>{previewCase.starter}</p>
</div>
<div className="omni-preset-actions">
<button type="button" onClick={() => setPreviewCase(null)}></button>
<button type="button" className="primary" onClick={() => applyPreset(previewCase)}>使</button>
</div>
</div>
</section>
)}
</div>
</div>
</section>
);
}
export function OmniHistoryPage({
navigate,
onNotify,
}: {
navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) {
const [filter, setFilter] = useState<"all" | "running" | "completed">("all");
const [items, setItems] = useState<CreationConversation[] | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CreationConversation | null>(null);
// 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍
const notifyRef = useRef(onNotify);
notifyRef.current = onNotify;
useEffect(() => {
let cancelled = false;
setItems(null);
api
.listCreations(filter === "all" ? {} : { status: filter })
.then((page) => {
if (!cancelled) setItems(page.results || []);
})
.catch((error) => {
if (cancelled) return;
setItems([]);
notifyRef.current?.("error", (error as Error).message);
});
return () => {
cancelled = true;
};
}, [filter]);
const remove = async (id: string) => {
// 软删:会话没了,已生成的图和视频仍留在资产库里
setItems((prev) => (prev || []).filter((item) => item.id !== id));
try {
await api.deleteCreation(id);
} catch (error) {
notifyRef.current?.("error", (error as Error).message);
}
};
return (
<section className="page-view omni-history-page">
<header className="omni-history-header">
<div className="omni-history-heading">
<span>ALL-IN-ONE CREATION</span>
<div className="omni-history-title">
<button type="button" className="omni-history-back" aria-label="返回" onClick={() => navigate("omniCreate")}>
<ArrowLeft />
</button>
<h1></h1>
</div>
<p></p>
</div>
<div className="omni-history-tools">
<div className="omni-history-toolbar">
{([
["all", "全部"],
["running", "进行中"],
["completed", "已完成"],
] as const).map(([key, label]) => (
<button
type="button"
key={key}
className={filter === key ? "active" : ""}
onClick={() => setFilter(key)}
>
{label}
</button>
))}
</div>
<button type="button" className="omni-history-new" onClick={() => navigate("omniCreate")}>
<Plus />
</button>
</div>
</header>
<div className="omni-history-list">
{items === null ? (
<div className="omni-history-empty">
<p></p>
</div>
) : items.length === 0 ? (
<div className="omni-history-empty">
<p></p>
<button type="button" onClick={() => navigate("omniCreate")}>
<ChevronRight size={14} />
</button>
</div>
) : (
items.map((item) => (
<article
className="omni-history-item"
key={item.id}
data-status={item.status}
onClick={() => navigate("omniSession", { conversationId: item.id })}
>
{item.cover_url ? (
<img src={item.cover_url} alt={item.title} />
) : (
<span className="omni-history-placeholder">
{item.mode === "video" ? <Video /> : <ImageIcon />}
</span>
)}
<div>
<span
className={`omni-history-status${item.status === "completed" ? " completed" : ""}`}
>
{item.status === "completed" ? "已完成" : "进行中"}
</span>
<h2>{item.title}</h2>
<p>
{[
item.preset || "自由创作",
item.mode === "video" ? "视频" : "图片",
item.params?.model,
item.params?.ratio,
]
.filter(Boolean)
.join(" · ")}
</p>
<small>{formatRelativeTime(item.last_active_at)}</small>
</div>
<div className="omni-history-actions">
<button
type="button"
aria-label="删除项目"
onClick={(event) => {
event.stopPropagation();
setDeleteTarget(item);
}}
>
<Trash2 />
</button>
<ChevronRight />
</div>
</article>
))
)}
</div>
<ConfirmModal
open={Boolean(deleteTarget)}
title="删除会话"
icon={<Trash2 size={16} />}
detail={`确定删除「${deleteTarget?.title || "未命名创作"}」?会话会从创作历史中移除,已生成的图和视频仍留在资产库里。`}
confirmText="删除"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => {
const id = deleteTarget?.id;
setDeleteTarget(null);
if (id) void remove(id);
}}
/>
</section>
);
}
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -444,13 +444,13 @@ const PROJ_TABS: Array<{ filter: "all" | "draft" | "wip" | "done" | "fail"; labe
type ProjTab = (typeof PROJ_TABS)[number]["filter"];
const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string; primary?: boolean }> = [
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/yz/video-quick.jpg", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/yz/video-pro.jpg", primary: true },
{ title: "一键成片", desc: "输入商品名称并上传图片,自动完成脚本、场景与视频生成。", page: "quickCreate", image: "/assets/prototype/video-oneclick-film-v3.png", primary: true },
{ title: "专业创作", desc: "逐步控制脚本结构、商品与人物资产和最终视频。", page: "projectWizard", image: "/assets/prototype/photo-1485846234645-a62644f84728.jpg", primary: true },
];
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/yz/video-replace.jpg" },
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string; style?: React.CSSProperties }> = [
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/prototype/video-free-film-v3.png" },
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/prototype/video-prompt-extract-film-v3.png" },
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "videoReplace", image: "/assets/prototype/video-remix-film-v3.png", style: { objectPosition: "center 58%" } },
];
function isQuickCreateProject(project: Project) {
@@ -622,7 +622,7 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
<div className="vc-tools three">
{VIDEO_TOOLS.map((card) => (
<button className="vc-tool" type="button" key={card.title} onClick={() => navigate(card.page)}>
<div className="vc-tool-cover"><img src={card.image} alt="" /></div>
<div className="vc-tool-cover"><img src={card.image} alt="" style={card.style} /></div>
<div className="vc-tool-body">
<div className="vc-tool-title">
<h3>{card.title}</h3>
+29 -1
View File
@@ -29,6 +29,9 @@ export type Page =
| "assetFactory"
| "freeCreate"
| "quickCreate"
| "omniCreate"
| "omniHistory"
| "omniSession"
| "videoRemix"
| "videoReplace"
| "imageOptimize"
@@ -46,6 +49,12 @@ export type ResolvedRoute = {
authMode: AuthMode;
productId?: string;
projectId?: string;
// 全能创作对话页的会话 id(/omni-session/<id>)
conversationId?: string;
// 从首页「开始创作」带过来的首条消息。**只经 route state 透传,不进 URL** ——
// 刷新后它已经在库里了,再发一遍会重复。
firstMessage?: string;
firstRefs?: import("../types").CreationRef[];
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
tab?: string;
@@ -55,6 +64,9 @@ export type ResolvedRoute = {
export type NavigateOptions = {
productId?: string;
projectId?: string;
conversationId?: string;
firstMessage?: string;
firstRefs?: import("../types").CreationRef[];
replace?: boolean;
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
@@ -107,11 +119,14 @@ export const routeLabels: Record<Page, string> = {
team: "团队",
messages: "消息",
assetFactory: "图片工具",
omniCreate: "全能创作",
omniHistory: "创作历史",
omniSession: "全能创作",
freeCreate: "自由创作",
quickCreate: "一键成片",
videoRemix: "提炼提示词",
videoReplace: "视频复刻",
imageOptimize: "图片创作",
imageOptimize: "自由创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
modelPhotoDemoB: "模特图方案 B",
@@ -128,6 +143,8 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
if (page === "omniHistory") return "omniCreate";
if (page === "omniSession") return "omniHistory";
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "videoReplace", "pipeline"].includes(page)) return "projects";
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory";
@@ -170,6 +187,11 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/team") return { page: "team", authMode: "login", hash };
if (path === "/messages") return { page: "messages", authMode: "login", hash };
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
if (path === "/omni-create") return { page: "omniCreate", authMode: "login", hash };
if (path === "/omni-history") return { page: "omniHistory", authMode: "login", hash };
if (path.startsWith("/omni-session/")) {
return { page: "omniSession", authMode: "login", conversationId: decodeURIComponent(path.slice("/omni-session/".length)), hash };
}
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
if (path === "/quick-create") {
return { page: "quickCreate", authMode: "login", productId: search.get("product_id") || undefined, hash };
@@ -217,6 +239,12 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/messages";
case "assetFactory":
return "/asset-factory";
case "omniCreate":
return "/omni-create";
case "omniHistory":
return "/omni-history";
case "omniSession":
return options.conversationId ? `/omni-session/${encodeURIComponent(options.conversationId)}` : "/omni-history";
case "freeCreate":
return "/free-create";
case "quickCreate":