726 lines
32 KiB
TypeScript
726 lines
32 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ArrowLeft,
|
|
Box,
|
|
ChevronRight,
|
|
FolderOpen,
|
|
History,
|
|
Image as ImageIcon,
|
|
LoaderCircle,
|
|
Play,
|
|
Plus,
|
|
Sparkles,
|
|
Trash2,
|
|
Upload,
|
|
UserRound,
|
|
Users,
|
|
Video,
|
|
WandSparkles,
|
|
X,
|
|
} from "lucide-react";
|
|
import { api, ApiError } from "../api";
|
|
import { findCatalogModel } from "../components/omni-param-bar";
|
|
import { modelResolutions } from "../components/free-create/constants";
|
|
import { ConfirmModal } from "../components/overlays";
|
|
import type { CreationConversation, CreationRef, ModelConfig } 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;
|
|
previewVideo?: string;
|
|
};
|
|
|
|
const VIDEO_PRESETS: PresetItem[] = [
|
|
{ name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.mp4" },
|
|
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" },
|
|
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" },
|
|
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" },
|
|
{ name: "多色商品换款", category: "visual", mode: "video", title: "多色商品换款", desc: "统一商品比例与机位,用动作连续展示不同颜色和款式。", starter: "创作一条多色商品换款视频:统一构图展示不同颜色或款式,用明确动作触发切换,最后给出全款总览。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" },
|
|
{ name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" },
|
|
];
|
|
|
|
const IMAGE_PRESETS: PresetItem[] = [
|
|
{ name: "电影感", category: "style", mode: "image", title: "电影感", cover: "/assets/image-presets/cinematic.png" },
|
|
{ name: "日式清新", category: "style", mode: "image", title: "日式清新", cover: "/assets/image-presets/japanese-fresh.png" },
|
|
{ name: "复古胶片", category: "style", mode: "image", title: "复古胶片", cover: "/assets/image-presets/film-vintage.png" },
|
|
{ name: "暖调生活感", category: "style", mode: "image", title: "暖调生活感", cover: "/assets/image-presets/warm-lifestyle.png" },
|
|
{ name: "高质感杂志", category: "style", mode: "image", title: "高质感杂志", cover: "/assets/image-presets/editorial.png" },
|
|
{ name: "梦幻柔光", category: "style", mode: "image", title: "梦幻柔光", cover: "/assets/image-presets/dreamy-soft.png" },
|
|
{ name: "黑金高级", category: "style", mode: "image", title: "黑金高级", cover: "/assets/image-presets/black-gold.png" },
|
|
{ name: "冷峻科技", category: "style", mode: "image", title: "冷峻科技", cover: "/assets/image-presets/tech-cool.png" },
|
|
{ name: "赛博朋克", category: "style", mode: "image", title: "赛博朋克", cover: "/assets/image-presets/cyberpunk.png" },
|
|
{ name: "日系手绘动画", category: "style", mode: "image", title: "日系手绘动画", cover: "/assets/image-presets/japanese-hand-drawn-anime.png" },
|
|
{ name: "国风水墨", category: "style", mode: "image", title: "国风水墨", cover: "/assets/image-presets/chinese-ink-wash.png" },
|
|
{ name: "新中式", category: "style", mode: "image", title: "新中式", cover: "/assets/image-presets/neo-chinese.png" },
|
|
];
|
|
|
|
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 mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) {
|
|
if (type !== "asset") return results;
|
|
const q = query.trim().toLowerCase();
|
|
const extras = uploads.filter((item) => !q || item.name.toLowerCase().includes(q));
|
|
const seen = new Set(extras.map((item) => item.id));
|
|
return [...extras, ...results.filter((item) => !seen.has(item.id))];
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
export function OmniCreatePage({
|
|
modelConfigs,
|
|
navigate,
|
|
onNotify,
|
|
}: {
|
|
modelConfigs?: ModelConfig[];
|
|
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 [sessionUploads, setSessionUploads] = useState<CreationRef[]>([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null);
|
|
const [model, setModel] = useState("Seedance 2.5");
|
|
const [resolution, setResolution] = useState("1080p");
|
|
const [ratio, setRatio] = useState("9:16");
|
|
const [duration, setDuration] = useState("智能时长");
|
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
|
const toolsRef = useRef<HTMLDivElement>(null);
|
|
const [starting, setStarting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (outputMode === "image") {
|
|
const img = findCatalogModel(modelConfigs, "", "image");
|
|
setModel(img ? (img.display_name || img.name) : "Seedream5.0");
|
|
setResolution("模型默认");
|
|
setRatio("1:1");
|
|
setDuration("1 张");
|
|
} else {
|
|
const vid = findCatalogModel(modelConfigs, "", "video");
|
|
const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5";
|
|
const resList = modelResolutions(vid);
|
|
setModel(label);
|
|
setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p"));
|
|
setRatio("9:16");
|
|
setDuration("智能时长");
|
|
}
|
|
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
|
|
}, [outputMode, modelConfigs]);
|
|
|
|
useEffect(() => {
|
|
const onDown = (event: MouseEvent) => {
|
|
const target = event.target as Node;
|
|
if (toolsRef.current?.contains(target)) return;
|
|
setUploadMenuOpen(false);
|
|
setMentionMenuOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onDown);
|
|
return () => document.removeEventListener("mousedown", onDown);
|
|
}, []);
|
|
|
|
const presets = useMemo(() => {
|
|
return outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
|
|
}, [outputMode]);
|
|
|
|
const applyPreset = (preset: PresetItem) => {
|
|
setSelectedCase(preset);
|
|
// 预设只作为后台的创作方向。无论图片还是视频,都不把内部生成指令塞进用户输入区。
|
|
setPreviewCase(null);
|
|
onNotify?.("info", `已选择预设:${preset.name}`);
|
|
};
|
|
|
|
const openMentions = async (query = "", type: CreationRef["type"] = mentionTab) => {
|
|
setMentionTab(type);
|
|
setMentionMenuOpen(true);
|
|
setUploadMenuOpen(false);
|
|
setMentionLoading(true);
|
|
setMentionResults([]);
|
|
try {
|
|
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
|
setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type));
|
|
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 = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(event.target.files || []);
|
|
event.target.value = "";
|
|
setUploadMenuOpen(false);
|
|
if (!files.length) return;
|
|
const images = files.filter((file) => file.type.startsWith("image/"));
|
|
if (images.length !== files.length) {
|
|
onNotify?.("info", "全能创作仅支持上传图片");
|
|
}
|
|
if (!images.length) return;
|
|
setUploading(true);
|
|
setUploadProgress({ current: 1, total: images.length });
|
|
try {
|
|
for (const [index, file] of images.entries()) {
|
|
setUploadProgress({ current: index + 1, total: images.length });
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const data = await api.uploadFreeVideoRef(form);
|
|
const ref: CreationRef = {
|
|
type: "asset",
|
|
id: data.asset_id,
|
|
name: data.name || file.name,
|
|
cover: data.thumb_url || data.url,
|
|
};
|
|
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
|
setPendingRefs((prev) => {
|
|
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
|
return [...prev, ref];
|
|
});
|
|
}
|
|
} catch (error) {
|
|
onNotify?.("error", (error as Error).message);
|
|
} finally {
|
|
setUploading(false);
|
|
setUploadProgress(null);
|
|
}
|
|
};
|
|
|
|
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-start-context" hidden={!selectedCase && pendingRefs.length === 0}>
|
|
<div className="omni-selected-case" hidden={!selectedCase}>
|
|
<span>
|
|
<WandSparkles />
|
|
<strong>{selectedCase?.title}</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>
|
|
))}</div>
|
|
</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${uploading ? " is-uploading" : ""}`}
|
|
aria-label={uploading ? "正在上传参考素材" : "添加参考素材"}
|
|
aria-busy={uploading}
|
|
disabled={uploading}
|
|
onClick={() => {
|
|
setUploadMenuOpen((open) => !open);
|
|
setMentionMenuOpen(false);
|
|
}}
|
|
>
|
|
{uploading ? <LoaderCircle /> : <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>
|
|
{uploading ? (
|
|
<span className="omni-upload-status" role="status" aria-live="polite">
|
|
<LoaderCircle aria-hidden="true" />
|
|
上传中 {uploadProgress?.current || 1}/{uploadProgress?.total || 1}
|
|
</span>
|
|
) : null}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
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);
|
|
}}
|
|
>
|
|
@
|
|
</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>
|
|
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="omni-start-generate"
|
|
disabled={starting || uploading}
|
|
onClick={() => {
|
|
const text = prompt.trim();
|
|
if (!text && !selectedCase && pendingRefs.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, firstUploads: sessionUploads });
|
|
})
|
|
.catch((error) => {
|
|
const status = (error as { status?: number }).status;
|
|
onNotify?.(
|
|
status === 409 ? "info" : "error",
|
|
(error as Error).message || "创建失败",
|
|
);
|
|
setStarting(false);
|
|
});
|
|
}}
|
|
>
|
|
{uploading ? "图片上传中…" : starting ? "正在创建…" : "开始创作"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
className={`omni-case-library${outputMode === "image" ? " is-image-presets" : ""}`}
|
|
aria-label={outputMode === "image" ? "图片风格预设" : "视频创作预设"}
|
|
>
|
|
<div className="omni-preset-intro">
|
|
<strong>{outputMode === "video" ? "从一个视频方向开始" : "从一个风格开始"}</strong>
|
|
<span>{outputMode === "video" ? "可选,也可以直接描述你想拍的视频。" : "可选,也可以直接描述你想要的画面。"}</span>
|
|
</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}
|
|
onMouseEnter={(event) => {
|
|
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
|
void video?.play().catch(() => undefined);
|
|
}}
|
|
onMouseLeave={(event) => {
|
|
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
|
if (!video) return;
|
|
video.pause();
|
|
video.currentTime = 0;
|
|
}}
|
|
onFocus={(event) => {
|
|
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
|
void video?.play().catch(() => undefined);
|
|
}}
|
|
onBlur={(event) => {
|
|
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
|
|
if (!video) return;
|
|
video.pause();
|
|
video.currentTime = 0;
|
|
}}
|
|
onClick={() => {
|
|
if (card.mode === "image") applyPreset(card);
|
|
else setPreviewCase(card);
|
|
}}
|
|
>
|
|
<span className="omni-case-visual">
|
|
{card.previewVideo ? (
|
|
<video muted loop playsInline preload="metadata" poster={card.cover} aria-label={`${card.title} 样片预览`}>
|
|
<source src={card.previewVideo} type="video/mp4" />
|
|
</video>
|
|
) : (
|
|
<img src={card.cover} alt="" />
|
|
)}
|
|
</span>
|
|
<span className="omni-case-copy">
|
|
<strong>{card.title}</strong>
|
|
{card.desc ? <small>{card.desc}</small> : null}
|
|
</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">
|
|
{previewCase.previewVideo ? (
|
|
<video controls playsInline preload="metadata" poster={previewCase.cover}>
|
|
<source src={previewCase.previewVideo} type="video/mp4" />
|
|
当前浏览器不支持播放该示例视频。
|
|
</video>
|
|
) : (
|
|
<img src={previewCase.cover} alt="预设示例" />
|
|
)}
|
|
{previewCase.mode === "video" && !previewCase.previewVideo && (
|
|
<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>
|
|
{previewCase.desc ? <p>{previewCase.desc}</p> : null}
|
|
{previewCase.starter ? (
|
|
<div className="omni-preset-default">
|
|
<span>默认创作方向</span>
|
|
<p>{previewCase.starter}</p>
|
|
</div>
|
|
) : null}
|
|
<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 [modeFilter, setModeFilter] = useState<"all" | "video" | "image">("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(modeFilter === "all" ? {} : { mode: modeFilter })
|
|
.then((page) => {
|
|
if (!cancelled) setItems(page.results || []);
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
setItems([]);
|
|
notifyRef.current?.("error", (error as Error).message);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [modeFilter]);
|
|
|
|
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", "全部"],
|
|
["video", "视频创作"],
|
|
["image", "图片创作"],
|
|
] as const).map(([key, label]) => (
|
|
<button
|
|
type="button"
|
|
key={key}
|
|
className={modeFilter === key ? "active" : ""}
|
|
onClick={() => setModeFilter(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>
|
|
{modeFilter === "video"
|
|
? "还没有视频创作记录,从全能创作开始。"
|
|
: modeFilter === "image"
|
|
? "还没有图片创作记录,从全能创作开始。"
|
|
: "还没有创作记录,从全能创作开始。"}
|
|
</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.agent_status === "planning" ? "generating" : 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.agent_status === "planning"
|
|
? " generating"
|
|
: item.status === "completed"
|
|
? " completed"
|
|
: ""
|
|
}`}
|
|
>
|
|
{item.agent_status === "planning"
|
|
? "生成中"
|
|
: 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>
|
|
);
|
|
}
|