feat: 电商页面 KeepAlive 保活机制,切换页面不再丢失生成状态
通过 display:none 模式实现轻量 KeepAlive,电商页面首次访问后保持挂载, 切换到其他页面再切回时所有右侧面板状态(上传图片、生成进度、结果)完整保留。 同时清理项目中的临时文件和本地冗余图片。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,291 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
// ── Patch 1: context.js ──────────────────────────────────────
|
|
||||||
const ctxPath = "/opt/omniai-server/src/routes/context.js";
|
|
||||||
let ctx = fs.readFileSync(ctxPath, "utf8");
|
|
||||||
|
|
||||||
const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);";
|
|
||||||
const emailConsts = `
|
|
||||||
const EMAIL_PURPOSES = new Set(["register", "login", "reset"]);
|
|
||||||
const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10);
|
|
||||||
const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60);
|
|
||||||
const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`;
|
|
||||||
|
|
||||||
if (!ctx.includes("EMAIL_PURPOSES")) {
|
|
||||||
ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts);
|
|
||||||
console.log("[ctx] added EMAIL_PURPOSES");
|
|
||||||
}
|
|
||||||
|
|
||||||
const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}';
|
|
||||||
const emailFuncs = `
|
|
||||||
function hashEmailCode(email, code) {
|
|
||||||
const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret";
|
|
||||||
return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendEmailCode(email, code, purpose) {
|
|
||||||
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
|
|
||||||
|
|
||||||
if (provider === "smtp") {
|
|
||||||
const nodemailer = require("nodemailer");
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.SMTP_HOST,
|
|
||||||
port: Number(process.env.SMTP_PORT) || 587,
|
|
||||||
secure: process.env.SMTP_SECURE === "1",
|
|
||||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
||||||
});
|
|
||||||
|
|
||||||
const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801";
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
|
||||||
to: email,
|
|
||||||
subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
|
|
||||||
text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002",
|
|
||||||
html: '<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px"><h2 style="color:#333">OmniAI \u90ae\u7bb1\u9a8c\u8bc1</h2><p style="font-size:16px;color:#555">\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a</p><p style="font-size:32px;font-weight:bold;letter-spacing:6px;color:#1677ff;margin:16px 0">' + code + '</p><p style="color:#888">\u7528\u9014\uff1a' + purposeText + '</p><p style="color:#888">\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f</p><hr style="border:none;border-top:1px solid #eee;margin:24px 0"><p style="color:#aaa;font-size:13px">\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002</p></div>',
|
|
||||||
});
|
|
||||||
return { provider: "smtp" };
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)");
|
|
||||||
return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function consumeEmailCode(email, code, purpose) {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
"SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
|
|
||||||
[email, purpose]
|
|
||||||
);
|
|
||||||
const row = rows[0];
|
|
||||||
if (!row) return false;
|
|
||||||
if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false;
|
|
||||||
|
|
||||||
const expectedHash = hashEmailCode(email, String(code || "").trim());
|
|
||||||
if (row.code_hash !== expectedHash) {
|
|
||||||
await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
|
|
||||||
return true;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
if (!ctx.includes("hashEmailCode")) {
|
|
||||||
ctx = ctx.replace(afterConsume, afterConsume + emailFuncs);
|
|
||||||
console.log("[ctx] added email functions");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update exports
|
|
||||||
if (!ctx.includes("EMAIL_PURPOSES,")) {
|
|
||||||
ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,");
|
|
||||||
}
|
|
||||||
if (!ctx.includes("hashEmailCode,")) {
|
|
||||||
ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,");
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(ctxPath, ctx, "utf8");
|
|
||||||
console.log("[ctx] written");
|
|
||||||
|
|
||||||
// ── Patch 2: auth.js ─────────────────────────────────────────
|
|
||||||
const authPath = "/opt/omniai-server/src/routes/auth.js";
|
|
||||||
let auth = fs.readFileSync(authPath, "utf8");
|
|
||||||
|
|
||||||
// 2a. Add imports inside context.js destructuring
|
|
||||||
if (!auth.includes("hashEmailCode,")) {
|
|
||||||
auth = auth.replace(
|
|
||||||
'} = require("./context");',
|
|
||||||
' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");'
|
|
||||||
);
|
|
||||||
console.log("[auth] added imports");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. Insert new routes before module.exports
|
|
||||||
const newRoutes = `
|
|
||||||
// ============================================================
|
|
||||||
// Email verification routes
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
router.post("/auth/email/send-code", async (req, res) => {
|
|
||||||
const email = normalizeEmail(req.body?.email);
|
|
||||||
const purpose = String(req.body?.purpose || "register");
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) return res.status(400).json({ error: emailError });
|
|
||||||
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
|
|
||||||
|
|
||||||
if (purpose === "register") {
|
|
||||||
const inviteOk = await ensureBetaInviteCode(req, res);
|
|
||||||
if (!inviteOk) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows: recentCodes } = await pool.query(
|
|
||||||
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
|
|
||||||
[email, purpose, EMAIL_CODE_COOLDOWN_SECONDS]
|
|
||||||
);
|
|
||||||
if (recentCodes.length > 0) {
|
|
||||||
return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (purpose === "register") {
|
|
||||||
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]);
|
|
||||||
if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (purpose === "login" || purpose === "reset") {
|
|
||||||
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
|
|
||||||
if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = generateSmsCode();
|
|
||||||
const codeHash = hashEmailCode(email, code);
|
|
||||||
await pool.query(
|
|
||||||
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)",
|
|
||||||
[email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendResult = await sendEmailCode(email, code, purpose);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
provider: sendResult.provider,
|
|
||||||
ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60,
|
|
||||||
cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS,
|
|
||||||
...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[auth/email/send-code] failed", error);
|
|
||||||
res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/auth/email/verify", async (req, res) => {
|
|
||||||
const email = normalizeEmail(req.body?.email);
|
|
||||||
const code = String(req.body?.code || "").trim();
|
|
||||||
const purpose = String(req.body?.purpose || "register");
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) return res.status(400).json({ error: emailError });
|
|
||||||
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
|
||||||
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verified = await consumeEmailCode(email, code, purpose);
|
|
||||||
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
|
||||||
if (purpose === "register" || purpose === "login") {
|
|
||||||
await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]);
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[auth/email/verify] failed", error);
|
|
||||||
res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/auth/forgot-password", async (req, res) => {
|
|
||||||
const email = normalizeEmail(req.body?.email);
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) return res.status(400).json({ error: emailError });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { rows: recentCodes } = await pool.query(
|
|
||||||
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
|
|
||||||
[email, EMAIL_CODE_COOLDOWN_SECONDS]
|
|
||||||
);
|
|
||||||
if (recentCodes.length > 0) {
|
|
||||||
return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = generateSmsCode();
|
|
||||||
const codeHash = hashEmailCode(email, code);
|
|
||||||
await pool.query(
|
|
||||||
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)",
|
|
||||||
[email, codeHash, EMAIL_CODE_TTL_MINUTES]
|
|
||||||
);
|
|
||||||
await sendEmailCode(email, code, "reset");
|
|
||||||
res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[auth/forgot-password] failed", error);
|
|
||||||
res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/auth/reset-password", async (req, res) => {
|
|
||||||
const email = normalizeEmail(req.body?.email);
|
|
||||||
const code = String(req.body?.code || "").trim();
|
|
||||||
const newPassword = String(req.body?.newPassword || "");
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) return res.status(400).json({ error: emailError });
|
|
||||||
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
|
||||||
const passwordError = validatePassword(newPassword);
|
|
||||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verified = await consumeEmailCode(email, code, "reset");
|
|
||||||
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
|
||||||
const hash = await bcrypt.hash(newPassword, 10);
|
|
||||||
await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]);
|
|
||||||
res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[auth/reset-password] failed", error);
|
|
||||||
res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!auth.includes("/auth/email/send-code")) {
|
|
||||||
const endMarker = "\n}\n\nmodule.exports = {";
|
|
||||||
auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {");
|
|
||||||
console.log("[auth] added new routes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2c. Update register-email to require verification code
|
|
||||||
// Replace: router.post("/auth/register-email" ... without code check
|
|
||||||
// With: router.post("/auth/register-email" ... with code verification
|
|
||||||
|
|
||||||
const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
|
|
||||||
const email = normalizeEmail(req.body?.email);
|
|
||||||
const usernameInput = String(req.body?.username || "").trim();
|
|
||||||
const password = String(req.body?.password || "");
|
|
||||||
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) return res.status(400).json({ error: emailError });
|
|
||||||
const passwordError = validatePassword(password);
|
|
||||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
|
||||||
const registrationInvite = await ensureRegistrationInvite(req, res);
|
|
||||||
if (!registrationInvite) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows: existingEmail }`;
|
|
||||||
|
|
||||||
const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
|
|
||||||
const email = normalizeEmail(req.body?.email);
|
|
||||||
const usernameInput = String(req.body?.username || "").trim();
|
|
||||||
const password = String(req.body?.password || "");
|
|
||||||
const code = String(req.body?.code || "").trim();
|
|
||||||
|
|
||||||
const emailError = validateEmail(email);
|
|
||||||
if (emailError) return res.status(400).json({ error: emailError });
|
|
||||||
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
|
||||||
const passwordError = validatePassword(password);
|
|
||||||
if (passwordError) return res.status(400).json({ error: passwordError });
|
|
||||||
const registrationInvite = await ensureRegistrationInvite(req, res);
|
|
||||||
if (!registrationInvite) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verified = await consumeEmailCode(email, code, "register");
|
|
||||||
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
|
||||||
|
|
||||||
const { rows: existingEmail }`;
|
|
||||||
|
|
||||||
if (auth.includes(oldRegisterEmail)) {
|
|
||||||
auth = auth.replace(oldRegisterEmail, newRegisterEmail);
|
|
||||||
console.log("[auth] updated register-email with verification");
|
|
||||||
} else {
|
|
||||||
console.log("[auth] WARNING: register-email pattern not found, skipping");
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(authPath, auth, "utf8");
|
|
||||||
console.log("[auth] written");
|
|
||||||
console.log("\nDone.");
|
|
||||||
+26
-15
@@ -14,7 +14,7 @@ import {
|
|||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
WalletOutlined,
|
WalletOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { reportError } from "./utils/errorReporting";
|
import { reportError } from "./utils/errorReporting";
|
||||||
import { initNotificationPermission } from "./utils/generationNotifier";
|
import { initNotificationPermission } from "./utils/generationNotifier";
|
||||||
@@ -284,6 +284,12 @@ function App() {
|
|||||||
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
|
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
|
||||||
const clearAppState = useAppStore((s) => s.clearAppState);
|
const clearAppState = useAppStore((s) => s.clearAppState);
|
||||||
|
|
||||||
|
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
||||||
|
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
||||||
|
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Dismiss boot splash after first render
|
// Dismiss boot splash after first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const splash = document.getElementById("app-boot-splash");
|
const splash = document.getElementById("app-boot-splash");
|
||||||
@@ -1075,20 +1081,7 @@ function App() {
|
|||||||
return <AssetsPage isAuthenticated={Boolean(session)} onOpenLogin={handleOpenLogin} />;
|
return <AssetsPage isAuthenticated={Boolean(session)} onOpenLogin={handleOpenLogin} />;
|
||||||
case "ecommerce":
|
case "ecommerce":
|
||||||
case "ecommerceHub":
|
case "ecommerceHub":
|
||||||
return (
|
return null;
|
||||||
<EcommercePage
|
|
||||||
projects={projects}
|
|
||||||
isAuthenticated={Boolean(session)}
|
|
||||||
onStartCreate={handleStartCreate}
|
|
||||||
onOpenProject={handleOpenProject}
|
|
||||||
onDeleteProject={handleDeleteProject}
|
|
||||||
onImportWorkflow={handleImportWorkflow}
|
|
||||||
onCreateTask={handleCreateTask}
|
|
||||||
onRequireLogin={handleRequireTaskLogin}
|
|
||||||
initialTemplate={pendingEcommerceTemplate}
|
|
||||||
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "digitalHuman":
|
case "digitalHuman":
|
||||||
return (
|
return (
|
||||||
<DigitalHumanPage
|
<DigitalHumanPage
|
||||||
@@ -1241,6 +1234,24 @@ function App() {
|
|||||||
<PageTransition viewKey={activeView}>
|
<PageTransition viewKey={activeView}>
|
||||||
{activePage}
|
{activePage}
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
|
|
||||||
|
{/* KeepAlive: EcommercePage stays mounted once visited */}
|
||||||
|
{ecommerceEverMounted && (
|
||||||
|
<div style={{ display: isEcommerceActive ? undefined : "none" }}>
|
||||||
|
<EcommercePage
|
||||||
|
projects={projects}
|
||||||
|
isAuthenticated={Boolean(session)}
|
||||||
|
onStartCreate={handleStartCreate}
|
||||||
|
onOpenProject={handleOpenProject}
|
||||||
|
onDeleteProject={handleDeleteProject}
|
||||||
|
onImportWorkflow={handleImportWorkflow}
|
||||||
|
onCreateTask={handleCreateTask}
|
||||||
|
onRequireLogin={handleRequireTaskLogin}
|
||||||
|
initialTemplate={pendingEcommerceTemplate}
|
||||||
|
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const COOKIE_CONSENT_KEY = "omniai:cookie-consent:v1";
|
||||||
|
|
||||||
|
export default function CookieConsentBanner() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(localStorage.getItem(COOKIE_CONSENT_KEY) !== "accepted");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const accept = () => {
|
||||||
|
localStorage.setItem(COOKIE_CONSENT_KEY, "accepted");
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="cookie-consent" role="dialog" aria-live="polite" aria-label="Cookie 使用提示">
|
||||||
|
<div>
|
||||||
|
<strong>Cookie 与本地存储提示</strong>
|
||||||
|
<p>我们使用 Cookie 和本地存储保存登录状态、偏好设置、创作草稿和断点续传数据,用于保障服务正常运行。</p>
|
||||||
|
</div>
|
||||||
|
<div className="cookie-consent__actions">
|
||||||
|
<a href="#/privacyPolicy">查看隐私政策</a>
|
||||||
|
<button type="button" onClick={accept}>同意并继续</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,8 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
|
|||||||
|
|
||||||
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
|
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
|
||||||
|
|
||||||
|
if (!displayedChildren) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : "page-transition-wrap"}>
|
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : "page-transition-wrap"}>
|
||||||
{displayedChildren}
|
{displayedChildren}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
type ComplianceKind = "agreement" | "privacy";
|
||||||
|
|
||||||
|
interface CompliancePageProps {
|
||||||
|
kind: ComplianceKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyName = "OmniAI";
|
||||||
|
const contactPhone = "15155073618";
|
||||||
|
const address = "江苏省南京市江北新区扬子江数字视听产业园9栋A楼501";
|
||||||
|
|
||||||
|
const agreementSections = [
|
||||||
|
{
|
||||||
|
title: "服务范围",
|
||||||
|
body: "平台提供 AI 图片、视频、脚本、数字人及相关创作辅助服务。具体功能、模型能力、消耗规则以页面展示和平台公告为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "账号与使用",
|
||||||
|
body: "用户应保证注册信息真实有效,妥善保管账号与登录凭证,不得出租、转让账号或以自动化方式恶意占用平台资源。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "内容合规",
|
||||||
|
body: "用户不得上传、生成、发布违法违规、侵权、涉政敏感、暴恐、色情、赌博、诈骗或侵犯他人合法权益的内容。平台有权对违规内容采取删除、限制功能、封禁账号等措施。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "积分与付费",
|
||||||
|
body: "积分仅限平台内消费,不支持提现、转让或折现。充值、套餐、赠送积分的有效期、消耗顺序和退费规则以充值页面展示为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "责任限制",
|
||||||
|
body: "AI 生成结果可能存在偏差,用户应自行审核输出内容并承担使用后果。因不可抗力、第三方服务异常、网络故障造成的服务中断,平台将在合理范围内修复。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const privacySections = [
|
||||||
|
{
|
||||||
|
title: "收集的信息",
|
||||||
|
body: "我们会收集账号信息、登录状态、联系方式、创作输入、生成结果、用量记录、设备与网络日志,用于提供服务、安全审计和问题排查。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cookie 与本地存储",
|
||||||
|
body: "我们使用 Cookie、localStorage 和 sessionStorage 保存登录状态、偏好设置、Cookie 同意状态、创作草稿和断点续传数据。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "信息使用",
|
||||||
|
body: "信息用于身份验证、生成任务处理、资产管理、积分计费、客服支持、风控合规、服务优化和法律法规要求的备案审计。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "第三方处理",
|
||||||
|
body: "为完成 AI 生成、对象存储、短信邮件、支付或错误监控,我们可能向必要的第三方服务提供最小范围数据,并要求其按约定保护数据安全。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "用户权利",
|
||||||
|
body: "你可以通过平台账号功能或联系方式申请访问、更正、删除个人信息,或撤回非必要授权。法律法规另有要求的记录可能需按规定保留。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CompliancePage({ kind }: CompliancePageProps) {
|
||||||
|
const isPrivacy = kind === "privacy";
|
||||||
|
const sections = isPrivacy ? privacySections : agreementSections;
|
||||||
|
const title = isPrivacy ? "隐私政策" : "用户协议";
|
||||||
|
const Icon = isPrivacy ? SafetyOutlined : FileTextOutlined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="compliance-page">
|
||||||
|
<div className="compliance-page__inner">
|
||||||
|
<header className="compliance-hero">
|
||||||
|
<span className="compliance-hero__icon"><Icon /></span>
|
||||||
|
<div>
|
||||||
|
<span className="compliance-hero__eyebrow">合规文件</span>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{companyName} 平台服务合规说明。更新日期:2026 年 6 月 3 日。</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="compliance-card">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<article key={section.title} className="compliance-section">
|
||||||
|
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<div>
|
||||||
|
<h2>{section.title}</h2>
|
||||||
|
<p>{section.body}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="compliance-contact">
|
||||||
|
<strong>联系我们</strong>
|
||||||
|
<span>地址:{address}</span>
|
||||||
|
<span>电话:{contactPhone}</span>
|
||||||
|
<span>备案号:苏ICP备2026021747号-1</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2064,47 +2064,168 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{status === "done" ? (
|
{cloneOutput === "video" ? (
|
||||||
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
<>
|
||||||
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
|
||||||
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
{/* Source Node — 原图素材 */}
|
||||||
<span>原图素材</span>
|
<div className="clone-ai-flow-source">
|
||||||
</button>
|
<div className="clone-ai-flow-node clone-ai-flow-node--source">
|
||||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
{productImages[0]?.src ? (
|
||||||
<div className="clone-ai-result-grid result-reveal">
|
<img src={productImages[0].src} alt="商品原图" />
|
||||||
{cloneOutput === "set" ? (
|
) : (
|
||||||
clonePreviewCards.map((card) => (
|
<div className="clone-ai-flow-node__placeholder">
|
||||||
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
<FileImageOutlined />
|
||||||
<img src={card.src} alt={card.label} />
|
</div>
|
||||||
<span>{card.label}</span>
|
)}
|
||||||
</button>
|
</div>
|
||||||
))
|
<span className="clone-ai-flow-node__label">附件原图</span>
|
||||||
) : results[0]?.src ? (
|
</div>
|
||||||
<button type="button" onClick={() => openProductSetPreview(results[0])}>
|
|
||||||
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
{/* Connector — 分支连接线 */}
|
||||||
<span>{selectedCloneOutput.label}</span>
|
<div className="clone-ai-flow-connector" aria-hidden="true">
|
||||||
</button>
|
<div className="clone-ai-flow-connector__trunk" />
|
||||||
) : null}
|
<div className="clone-ai-flow-connector__branches">
|
||||||
</div>
|
<div className="clone-ai-flow-connector__branch" />
|
||||||
</section>
|
<div className="clone-ai-flow-connector__branch" />
|
||||||
) : (
|
<div className="clone-ai-flow-connector__branch" />
|
||||||
<section className="clone-ai-empty-state" aria-live="polite">
|
</div>
|
||||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
</div>
|
||||||
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
|
|
||||||
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
|
{/* Branches — 生成路径分支 */}
|
||||||
<span>
|
{status === "done" ? (
|
||||||
{status === "generating"
|
<div className="clone-ai-flow-branches">
|
||||||
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。`
|
{results[0]?.src ? (
|
||||||
: status === "failed"
|
<div className="clone-ai-flow-branch">
|
||||||
? "请检查网络后点击下方重试"
|
<div className="clone-ai-flow-node clone-ai-flow-node--text">
|
||||||
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
|
<div className="clone-ai-flow-node__text-content">
|
||||||
</span>
|
<span className="clone-ai-flow-node__text-title">{selectedCloneOutput.label}</span>
|
||||||
{status === "failed" && lastFailedActionRef.current ? (
|
<span className="clone-ai-flow-node__text-desc">{requirement || "AI智能生成"}</span>
|
||||||
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
|
</div>
|
||||||
<ReloadOutlined /> 重试
|
</div>
|
||||||
</button>
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clone-ai-flow-node clone-ai-flow-node--result"
|
||||||
|
onClick={() => openProductSetPreview(results[0])}
|
||||||
|
>
|
||||||
|
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||||
|
<span className="clone-ai-flow-node__tag">{selectedCloneOutput.label}</span>
|
||||||
|
</button>
|
||||||
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
|
<div className="clone-ai-flow-node clone-ai-flow-node--video">
|
||||||
|
<img src={results[0].src} alt="分镜视频" />
|
||||||
|
<span className="clone-ai-flow-node__tag clone-ai-flow-node__tag--accent">分镜视频</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="clone-ai-flow-branches clone-ai-flow-branches--empty">
|
||||||
|
{[1, 2, 3].map((branchIndex) => (
|
||||||
|
<div
|
||||||
|
key={branchIndex}
|
||||||
|
className={`clone-ai-flow-branch${status === "generating" ? " is-generating" : ""}${status === "failed" ? " is-failed" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="clone-ai-flow-node clone-ai-flow-node--text">
|
||||||
|
<div className="clone-ai-flow-node__text-content">
|
||||||
|
<span className="clone-ai-flow-node__text-title">分镜文本{branchIndex}</span>
|
||||||
|
<span className="clone-ai-flow-node__text-desc">
|
||||||
|
{status === "generating" ? "AI 解析中..." : "等待生成"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
|
<div className="clone-ai-flow-node clone-ai-flow-node--result">
|
||||||
|
<div className="clone-ai-flow-node__placeholder">
|
||||||
|
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
|
||||||
|
</div>
|
||||||
|
<span className="clone-ai-flow-node__tag">分镜图{branchIndex}</span>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
|
<div className="clone-ai-flow-node clone-ai-flow-node--video">
|
||||||
|
<div className="clone-ai-flow-node__placeholder">
|
||||||
|
{status === "generating" ? <LoadingOutlined /> : <FileImageOutlined />}
|
||||||
|
</div>
|
||||||
|
<span className="clone-ai-flow-node__tag">分镜视频{branchIndex}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Status Overlay — 生成状态覆盖层 */}
|
||||||
|
{status !== "done" ? (
|
||||||
|
<section className="clone-ai-flow-status" aria-live="polite">
|
||||||
|
{status === "generating" ? (
|
||||||
|
<>
|
||||||
|
<LoadingOutlined style={{ fontSize: 28 }} />
|
||||||
|
<strong>正在生成</strong>
|
||||||
|
<EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} />
|
||||||
|
<span>AI 正在为 {platform} / {market} 整理{selectedCloneOutput.label}。</span>
|
||||||
|
</>
|
||||||
|
) : status === "failed" ? (
|
||||||
|
<>
|
||||||
|
<FrownOutlined style={{ fontSize: 28 }} />
|
||||||
|
<strong>生成失败</strong>
|
||||||
|
<span>请检查网络后点击下方重试</span>
|
||||||
|
{lastFailedActionRef.current ? (
|
||||||
|
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
|
||||||
|
<ReloadOutlined /> 重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>上传商品原图并填写信息后,AI 将在这里展示生成结果。</span>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{status === "done" ? (
|
||||||
|
<section className="clone-ai-preview-showcase" aria-label="生成结果">
|
||||||
|
<button type="button" className="clone-ai-main-result" onClick={() => openProductSetPreview(cloneOutput === "set" ? clonePreviewCards[0] : results[0])}>
|
||||||
|
<img src={productImages[0]?.src ?? (cloneOutput === "set" ? clonePreviewCards[0].src : results[0]?.src ?? "")} alt="上传商品原图" />
|
||||||
|
<span>原图素材</span>
|
||||||
|
</button>
|
||||||
|
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||||
|
<div className="clone-ai-result-grid result-reveal">
|
||||||
|
{cloneOutput === "set" ? (
|
||||||
|
clonePreviewCards.map((card) => (
|
||||||
|
<button key={card.id} type="button" onClick={() => openProductSetPreview(card)}>
|
||||||
|
<img src={card.src} alt={card.label} />
|
||||||
|
<span>{card.label}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : results[0]?.src ? (
|
||||||
|
<button type="button" onClick={() => openProductSetPreview(results[0])}>
|
||||||
|
<img src={results[0].src} alt={selectedCloneOutput.label} />
|
||||||
|
<span>{selectedCloneOutput.label}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="clone-ai-empty-state" aria-live="polite">
|
||||||
|
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <FrownOutlined /> : <FileImageOutlined />}
|
||||||
|
<strong>{status === "generating" ? "正在生成" : status === "failed" ? "生成失败" : "等待生成"}</strong>
|
||||||
|
{status === "generating" ? <EcommerceProgressBar status="generating" label={`${selectedCloneOutput.label}生成`} /> : null}
|
||||||
|
<span>
|
||||||
|
{status === "generating"
|
||||||
|
? `AI 正在为 ${platform} / ${market} 整理${selectedCloneOutput.label}。`
|
||||||
|
: status === "failed"
|
||||||
|
? "请检查网络后点击下方重试"
|
||||||
|
: "上传商品原图并填写信息后,AI 将在这里展示生成结果。"}
|
||||||
|
</span>
|
||||||
|
{status === "failed" && lastFailedActionRef.current ? (
|
||||||
|
<button type="button" className="clone-ai-retry-btn" onClick={lastFailedActionRef.current}>
|
||||||
|
<ReloadOutlined /> 重试
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="clone-ai-bottom-input" aria-label="信息详情">
|
<section className="clone-ai-bottom-input" aria-label="信息详情">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
@@ -619,123 +619,126 @@ export default function EcommerceVideoWorkspace({
|
|||||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||||
{!sourceImage ? (
|
{!sourceImage ? (
|
||||||
<div className="ecom-video-empty">
|
<div className="ecom-video-empty">
|
||||||
<span>上传商品图并点击"一键策划"开始</span>
|
<span>上传商品图并点击“一键策划”开始</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="ecom-video-flow-map">
|
<div className="ecom-video-tree">
|
||||||
{/* Source image node */}
|
{/* Source Node — 附件原图 */}
|
||||||
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
|
<div className="ecom-video-tree__source">
|
||||||
<div className="ecom-video-flow-node__media">
|
<article className="ecom-video-tree-node ecom-video-tree-node--source">
|
||||||
<img src={sourceImage} alt="商品图" />
|
<img src={sourceImage} alt="商品原图" />
|
||||||
|
</article>
|
||||||
|
<span className="ecom-video-tree-node__label">附件原图</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch Connector — 分支连接线 */}
|
||||||
|
<div className="ecom-video-tree__trunk" aria-hidden="true">
|
||||||
|
<div className="ecom-video-tree__trunk-line" />
|
||||||
|
<div className="ecom-video-tree__branches-line">
|
||||||
|
{scenes.length > 0 ? scenes.map((s) => (
|
||||||
|
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
||||||
|
)) : (
|
||||||
|
<>
|
||||||
|
<div className="ecom-video-tree__branch-tap" />
|
||||||
|
<div className="ecom-video-tree__branch-tap" />
|
||||||
|
<div className="ecom-video-tree__branch-tap" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="ecom-video-flow-node__label">商品原图</span>
|
</div>
|
||||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{/* Connector: source → plan text nodes */}
|
{/* Branches — 每个场景一条分支 */}
|
||||||
{visiblePlanSteps.length > 0 ? (
|
<div className="ecom-video-tree__rows">
|
||||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
{scenes.length > 0 ? scenes.map((scene, idx) => {
|
||||||
) : null}
|
const planDone = completedSteps.length >= ALL_STEPS.length;
|
||||||
|
const imgReady = !!scene.imageUrl;
|
||||||
|
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||||||
|
const vidReady = scene.status === "completed" && scene.resultUrl;
|
||||||
|
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
||||||
|
const vidFailed = scene.status === "failed";
|
||||||
|
|
||||||
{/* Plan text nodes — side by side */}
|
return (
|
||||||
{visiblePlanSteps.length > 0 ? (
|
<div key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
|
||||||
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
|
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
|
||||||
{visiblePlanSteps.map((step, idx) => (
|
<div className="ecom-video-tree-node__inner">
|
||||||
<Fragment key={step}>
|
<span className="ecom-video-tree-node__title">分镜文本{scene.sceneId}</span>
|
||||||
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
|
<span className="ecom-video-tree-node__desc">
|
||||||
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
|
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
|
||||||
<span className="ecom-video-flow-node__text-icon">
|
</span>
|
||||||
{currentStep === step ? <LoadingOutlined /> : "✓"}
|
</div>
|
||||||
</span>
|
|
||||||
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
|
|
||||||
</article>
|
</article>
|
||||||
{idx < visiblePlanSteps.length - 1 ? (
|
|
||||||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Connector: plan → images */}
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||||
{hasImaging ? (
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Storyboard image nodes — side by side per scene */}
|
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`}>
|
||||||
{hasImaging ? (
|
{imgReady ? (
|
||||||
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
|
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||||||
{scenes.map((scene, idx) => {
|
) : (
|
||||||
const imgReady = !!scene.imageUrl;
|
<div className="ecom-video-tree-node__placeholder">
|
||||||
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
{imgRunning ? <LoadingOutlined /> : <span>待生成</span>}
|
||||||
const cls = imgReady ? "is-completed" : imgRunning ? "is-active" : "";
|
|
||||||
return (
|
|
||||||
<Fragment key={`img-${scene.sceneId}`}>
|
|
||||||
<article className={`ecom-video-flow-node ecom-video-flow-node--image ${cls}`}
|
|
||||||
aria-label={`分镜 ${scene.sceneId}`} title={`分镜 ${scene.sceneId}`}>
|
|
||||||
<div className="ecom-video-flow-node__media">
|
|
||||||
{imgReady ? <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
|
||||||
: imgRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
|
|
||||||
: <div className="ecom-video-flow-node__placeholder">待生成</div>}
|
|
||||||
</div>
|
</div>
|
||||||
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
|
)}
|
||||||
<span className="ecom-video-flow-node__label">分镜{scene.sceneId}</span>
|
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
<span className="ecom-video-tree-node__tag">分镜图{scene.sceneId}</span>
|
||||||
</article>
|
</article>
|
||||||
{idx < scenes.length - 1 ? (
|
|
||||||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Connector: images → videos */}
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||||
{hasRendering ? (
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Video nodes — side by side per scene */}
|
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`}>
|
||||||
{hasRendering ? (
|
{vidReady ? (
|
||||||
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
|
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||||
{scenes.map((scene, idx) => {
|
) : (
|
||||||
const vidReady = scene.status === "completed" && scene.resultUrl;
|
<div className="ecom-video-tree-node__placeholder">
|
||||||
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span>失败</span> : <span>待生成</span>}
|
||||||
const vidFailed = scene.status === "failed";
|
|
||||||
const cls = vidReady ? "is-completed" : vidRunning ? "is-active" : vidFailed ? "is-failed" : "";
|
|
||||||
return (
|
|
||||||
<Fragment key={`vid-${scene.sceneId}`}>
|
|
||||||
<article className={`ecom-video-flow-node ecom-video-flow-node--video ${cls}`}
|
|
||||||
aria-label={`镜头 ${scene.sceneId}`} title={`镜头 ${scene.sceneId}`}>
|
|
||||||
<div className="ecom-video-flow-node__media">
|
|
||||||
{vidReady ? <video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
|
||||||
: vidRunning ? <div className="ecom-video-flow-node__placeholder"><LoadingOutlined /></div>
|
|
||||||
: vidFailed ? <div className="ecom-video-flow-node__placeholder">失败</div>
|
|
||||||
: <div className="ecom-video-flow-node__placeholder">待生成</div>}
|
|
||||||
</div>
|
</div>
|
||||||
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
|
)}
|
||||||
<span className="ecom-video-flow-node__label">镜头{scene.sceneId}</span>
|
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||||
{vidFailed ? (
|
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||||
<button type="button" className="ecom-video-flow-node__retry"
|
{vidFailed ? (
|
||||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
<button type="button" className="ecom-video-tree-node__retry"
|
||||||
title="重试此镜头">
|
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||||||
<ReloadOutlined />
|
title="重试此镜头">
|
||||||
</button>
|
<ReloadOutlined />
|
||||||
) : null}
|
</button>
|
||||||
{vidFailed && scene.error ? (
|
|
||||||
<span className="ecom-video-flow-node__error" title={scene.error}>{scene.error.slice(0, 20)}</span>
|
|
||||||
) : null}
|
|
||||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
|
||||||
</article>
|
|
||||||
{idx < scenes.length - 1 ? (
|
|
||||||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Fragment>
|
</article>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
}) : (
|
||||||
) : null}
|
[1, 2, 3].map((n) => (
|
||||||
|
<div key={n} className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`} style={{ animationDelay: `${n * 120}ms` }}>
|
||||||
|
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||||||
|
<div className="ecom-video-tree-node__inner">
|
||||||
|
<span className="ecom-video-tree-node__title">分镜文本{n}</span>
|
||||||
|
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<article className="ecom-video-tree-node ecom-video-tree-node--image">
|
||||||
|
<div className="ecom-video-tree-node__placeholder">
|
||||||
|
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||||
|
</div>
|
||||||
|
<span className="ecom-video-tree-node__tag">分镜图{n}</span>
|
||||||
|
</article>
|
||||||
|
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<article className="ecom-video-tree-node ecom-video-tree-node--video">
|
||||||
|
<div className="ecom-video-tree-node__placeholder">
|
||||||
|
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||||
|
</div>
|
||||||
|
<span className="ecom-video-tree-node__tag">分镜视频{n}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const ECOMMERCE_SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||||
|
export const ECOMMERCE_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface EcommerceImageValidationResult {
|
||||||
|
accepted: File[];
|
||||||
|
rejected: Array<{ name: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEcommerceImageFiles(files: File[]): EcommerceImageValidationResult {
|
||||||
|
const accepted: File[] = [];
|
||||||
|
const rejected: EcommerceImageValidationResult["rejected"] = [];
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (!ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(file.type)) {
|
||||||
|
rejected.push({ name: file.name, reason: "不支持的图片格式" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > ECOMMERCE_MAX_IMAGE_BYTES) {
|
||||||
|
rejected.push({ name: file.name, reason: "图片超过 10MB" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
accepted.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accepted, rejected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeRejectedImages(rejected: EcommerceImageValidationResult["rejected"]): string {
|
||||||
|
if (!rejected.length) return "";
|
||||||
|
const first = rejected[0];
|
||||||
|
const suffix = rejected.length > 1 ? ` 等 ${rejected.length} 个文件` : "";
|
||||||
|
return `${first.name}${suffix} 已跳过:${first.reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEcommerceImageMime(type: string): string {
|
||||||
|
return ECOMMERCE_SUPPORTED_IMAGE_TYPES.has(type) ? type : "image/png";
|
||||||
|
}
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
import {
|
||||||
|
CloudUploadOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import type { ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
type CloneOutputKey = string;
|
||||||
|
type CloneSetCountKey = string;
|
||||||
|
type CloneModelPanelTab = "scene" | "model";
|
||||||
|
type CloneReferenceMode = "upload" | "link";
|
||||||
|
type CloneReplicateLevelKey = string;
|
||||||
|
type CloneVideoQualityKey = string;
|
||||||
|
|
||||||
|
interface CloneImageItem {
|
||||||
|
id: string;
|
||||||
|
src: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneBasicSelectItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneModelSelectItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneSetCountOption {
|
||||||
|
key: CloneSetCountKey;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneOutputOption {
|
||||||
|
key: CloneOutputKey;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneReplicateLevelOption {
|
||||||
|
key: CloneReplicateLevelKey;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneVideoQualityOption {
|
||||||
|
key: CloneVideoQualityKey;
|
||||||
|
label: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloneDetailModule {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EcommerceClonePanelProps {
|
||||||
|
productInputRef: RefObject<HTMLInputElement>;
|
||||||
|
cloneReferenceInputRef: RefObject<HTMLInputElement>;
|
||||||
|
productImages: CloneImageItem[];
|
||||||
|
isProductUploadDragging: boolean;
|
||||||
|
cloneOutput: CloneOutputKey;
|
||||||
|
cloneOutputOptions: CloneOutputOption[];
|
||||||
|
cloneBasicSelects: CloneBasicSelectItem[];
|
||||||
|
openCloneBasicSelect: string | null;
|
||||||
|
cloneReferenceMode: CloneReferenceMode;
|
||||||
|
cloneReferenceImages: CloneImageItem[];
|
||||||
|
maxCloneReferenceImages: number;
|
||||||
|
cloneReplicateLevel: CloneReplicateLevelKey;
|
||||||
|
cloneReplicateLevelOptions: CloneReplicateLevelOption[];
|
||||||
|
cloneSetCounts: Record<CloneSetCountKey, number>;
|
||||||
|
cloneSetCountOptions: CloneSetCountOption[];
|
||||||
|
cloneSetTotal: number;
|
||||||
|
minCloneSetTotal: number;
|
||||||
|
maxCloneSetTotal: number;
|
||||||
|
selectedCloneDetailModules: string[];
|
||||||
|
cloneDetailModules: CloneDetailModule[];
|
||||||
|
cloneModelPanelTab: CloneModelPanelTab;
|
||||||
|
tryOnScenes: string[];
|
||||||
|
selectedCloneModelScenes: string[];
|
||||||
|
cloneModelCustomScene: string;
|
||||||
|
cloneModelSelects: CloneModelSelectItem[];
|
||||||
|
openCloneModelSelect: string | null;
|
||||||
|
cloneModelSelectDropUp: boolean;
|
||||||
|
cloneModelAppearance: string;
|
||||||
|
cloneVideoQuality: CloneVideoQualityKey;
|
||||||
|
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
||||||
|
cloneVideoDuration: number;
|
||||||
|
cloneVideoDurationMin: number;
|
||||||
|
cloneVideoDurationMax: number;
|
||||||
|
cloneVideoDurationStyle: { [key: string]: number | string };
|
||||||
|
cloneVideoSmart: boolean;
|
||||||
|
canGenerate: boolean;
|
||||||
|
status: string;
|
||||||
|
lastFailedActionRef: MutableRefObject<(() => void) | null>;
|
||||||
|
setIsProductUploadDragging: (value: boolean) => void;
|
||||||
|
handleProductDrop: (event: DragEvent<HTMLElement>) => void;
|
||||||
|
removeProductImage: (id: string) => void;
|
||||||
|
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleCloneOutputChange: (value: CloneOutputKey) => void;
|
||||||
|
setOpenCloneBasicSelect: (value: string | null) => void;
|
||||||
|
setCloneReferenceMode: (value: CloneReferenceMode) => void;
|
||||||
|
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
|
||||||
|
startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void;
|
||||||
|
clearCloneSetCountHold: () => void;
|
||||||
|
toggleCloneDetailModule: (id: string) => void;
|
||||||
|
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
|
||||||
|
toggleCloneModelScene: (scene: string) => void;
|
||||||
|
setCloneModelCustomScene: (value: string) => void;
|
||||||
|
setOpenCloneModelSelect: (value: string | null) => void;
|
||||||
|
setCloneModelSelectDropUp: (value: boolean) => void;
|
||||||
|
setCloneModelAppearance: (value: string) => void;
|
||||||
|
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
||||||
|
setCloneVideoDuration: (value: number) => void;
|
||||||
|
clampCloneVideoDuration: (value: number) => number;
|
||||||
|
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
|
||||||
|
handleGenerate: () => void;
|
||||||
|
formatRatioDisplayValue: (value: string) => string;
|
||||||
|
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EcommerceClonePanel({
|
||||||
|
productInputRef,
|
||||||
|
cloneReferenceInputRef,
|
||||||
|
productImages,
|
||||||
|
isProductUploadDragging,
|
||||||
|
cloneOutput,
|
||||||
|
cloneOutputOptions,
|
||||||
|
cloneBasicSelects,
|
||||||
|
openCloneBasicSelect,
|
||||||
|
cloneReferenceMode,
|
||||||
|
cloneReferenceImages,
|
||||||
|
maxCloneReferenceImages,
|
||||||
|
cloneReplicateLevel,
|
||||||
|
cloneReplicateLevelOptions,
|
||||||
|
cloneSetCounts,
|
||||||
|
cloneSetCountOptions,
|
||||||
|
cloneSetTotal,
|
||||||
|
minCloneSetTotal,
|
||||||
|
maxCloneSetTotal,
|
||||||
|
selectedCloneDetailModules,
|
||||||
|
cloneDetailModules,
|
||||||
|
cloneModelPanelTab,
|
||||||
|
tryOnScenes,
|
||||||
|
selectedCloneModelScenes,
|
||||||
|
cloneModelCustomScene,
|
||||||
|
cloneModelSelects,
|
||||||
|
openCloneModelSelect,
|
||||||
|
cloneModelSelectDropUp,
|
||||||
|
cloneModelAppearance,
|
||||||
|
cloneVideoQuality,
|
||||||
|
cloneVideoQualityOptions,
|
||||||
|
cloneVideoDuration,
|
||||||
|
cloneVideoDurationMin,
|
||||||
|
cloneVideoDurationMax,
|
||||||
|
cloneVideoDurationStyle,
|
||||||
|
cloneVideoSmart,
|
||||||
|
canGenerate,
|
||||||
|
status,
|
||||||
|
lastFailedActionRef,
|
||||||
|
setIsProductUploadDragging,
|
||||||
|
handleProductDrop,
|
||||||
|
removeProductImage,
|
||||||
|
handleProductUpload,
|
||||||
|
handleCloneOutputChange,
|
||||||
|
setOpenCloneBasicSelect,
|
||||||
|
setCloneReferenceMode,
|
||||||
|
handleCloneReferenceUpload,
|
||||||
|
setCloneReplicateLevel,
|
||||||
|
startCloneSetCountHold,
|
||||||
|
clearCloneSetCountHold,
|
||||||
|
toggleCloneDetailModule,
|
||||||
|
setCloneModelPanelTab,
|
||||||
|
toggleCloneModelScene,
|
||||||
|
setCloneModelCustomScene,
|
||||||
|
setOpenCloneModelSelect,
|
||||||
|
setCloneModelSelectDropUp,
|
||||||
|
setCloneModelAppearance,
|
||||||
|
setCloneVideoQuality,
|
||||||
|
setCloneVideoDuration,
|
||||||
|
clampCloneVideoDuration,
|
||||||
|
setCloneVideoSmart,
|
||||||
|
handleGenerate,
|
||||||
|
formatRatioDisplayValue,
|
||||||
|
setVideoOutfitFiles,
|
||||||
|
}: EcommerceClonePanelProps) {
|
||||||
|
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
|
||||||
|
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
|
||||||
|
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleVideoOutfitVideoChange = () => {
|
||||||
|
const file = videoOutfitVideoRef.current?.files?.[0] || null;
|
||||||
|
if (file) setVideoOutfitVideoUrl(URL.createObjectURL(file));
|
||||||
|
setVideoOutfitFiles?.(file, videoOutfitRefRef.current?.files?.[0] || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoOutfitRefChange = () => {
|
||||||
|
const file = videoOutfitRefRef.current?.files?.[0] || null;
|
||||||
|
if (file) setVideoOutfitRefUrl(URL.createObjectURL(file));
|
||||||
|
setVideoOutfitFiles?.(videoOutfitVideoRef.current?.files?.[0] || null, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="product-clone-panel__scroll clone-ai-panel">
|
||||||
|
<header className="clone-ai-logo">
|
||||||
|
<span className="clone-ai-logo__mark">AI</span>
|
||||||
|
<strong>电商生成</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="clone-ai-card">
|
||||||
|
<h2>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
上传商品原图
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`clone-ai-upload-zone${isProductUploadDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => productInputRef.current?.click()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
productInputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsProductUploadDragging(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={() => setIsProductUploadDragging(false)}
|
||||||
|
onDrop={handleProductDrop}
|
||||||
|
>
|
||||||
|
<div className="clone-ai-upload-main">
|
||||||
|
<span className="clone-ai-upload-icon">
|
||||||
|
<FileImageOutlined />
|
||||||
|
</span>
|
||||||
|
<span className="clone-ai-upload-title">拖拽或点击上传</span>
|
||||||
|
<strong>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
上传图片
|
||||||
|
</strong>
|
||||||
|
<span className="clone-ai-upload-hint">同一产品,最多 7 张</span>
|
||||||
|
</div>
|
||||||
|
{productImages.length ? (
|
||||||
|
<div className="clone-ai-uploaded-files" aria-label="已上传商品原图">
|
||||||
|
{productImages.map((item) => (
|
||||||
|
<figure key={item.id} className="clone-ai-uploaded-file">
|
||||||
|
<img src={item.src} alt={item.name} />
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={item.src} alt="" />
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
removeProductImage(item.id);
|
||||||
|
}}
|
||||||
|
aria-label={`删除${item.name}`}
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<input ref={productInputRef} type="file" accept="image/*" multiple onChange={handleProductUpload} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="clone-ai-card">
|
||||||
|
<h2>
|
||||||
|
<SettingOutlined />
|
||||||
|
生成设置
|
||||||
|
</h2>
|
||||||
|
<div className="clone-ai-settings-section">
|
||||||
|
<span className="clone-ai-settings-label">生成内容</span>
|
||||||
|
<div className="clone-ai-tag-group" role="radiogroup" aria-label="生成内容">
|
||||||
|
{cloneOutputOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={cloneOutput === option.key ? "is-active" : ""}
|
||||||
|
aria-pressed={cloneOutput === option.key}
|
||||||
|
onClick={() => handleCloneOutputChange(option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-settings-section">
|
||||||
|
<span className="clone-ai-settings-label">基础设置</span>
|
||||||
|
<div className="clone-ai-select-group">
|
||||||
|
{cloneBasicSelects.map((item) => {
|
||||||
|
const hasMultipleOptions = item.options.length > 1;
|
||||||
|
const isOpen = hasMultipleOptions && openCloneBasicSelect === item.key;
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="clone-ai-basic-select" data-clone-basic-select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${isOpen ? "is-open" : ""}${hasMultipleOptions ? "" : " is-static"}`}
|
||||||
|
aria-expanded={hasMultipleOptions ? isOpen : undefined}
|
||||||
|
aria-haspopup={hasMultipleOptions ? "listbox" : undefined}
|
||||||
|
aria-controls={hasMultipleOptions ? `clone-basic-select-${item.key}` : undefined}
|
||||||
|
onClick={() => setOpenCloneBasicSelect(hasMultipleOptions ? (isOpen ? null : item.key) : null)}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<strong>{item.key === "ratio" ? formatRatioDisplayValue(item.value) : item.value}</strong>
|
||||||
|
{hasMultipleOptions ? <i aria-hidden="true" /> : null}
|
||||||
|
</button>
|
||||||
|
{hasMultipleOptions && isOpen ? (
|
||||||
|
<div id={`clone-basic-select-${item.key}`} className="clone-ai-basic-select__menu" role="listbox">
|
||||||
|
{item.options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={item.value === option ? "is-active" : ""}
|
||||||
|
role="option"
|
||||||
|
aria-selected={item.value === option}
|
||||||
|
onClick={() => {
|
||||||
|
item.onChange(option);
|
||||||
|
setOpenCloneBasicSelect(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.key === "ratio" ? formatRatioDisplayValue(option) : option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{cloneOutput === "hot" ? (
|
||||||
|
<section className="clone-ai-replicate-panel" aria-label="爆款图复刻设置">
|
||||||
|
<div className="clone-ai-replicate-section">
|
||||||
|
<span className="clone-ai-replicate-title">参考内容</span>
|
||||||
|
<div className="clone-ai-replicate-tabs" role="tablist" aria-label="参考内容来源">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cloneReferenceMode === "upload" ? "is-active" : ""}
|
||||||
|
aria-selected={cloneReferenceMode === "upload"}
|
||||||
|
onClick={() => setCloneReferenceMode("upload")}
|
||||||
|
>
|
||||||
|
上传参考图
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cloneReferenceMode === "link" ? "is-active" : ""}
|
||||||
|
aria-selected={cloneReferenceMode === "link"}
|
||||||
|
onClick={() => setCloneReferenceMode("link")}
|
||||||
|
>
|
||||||
|
导入链接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{cloneReferenceMode === "upload" ? (
|
||||||
|
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
|
||||||
|
<span>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
<span className="clone-ai-replicate-upload-text">添加图片</span>
|
||||||
|
</span>
|
||||||
|
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages} 张`}</em>
|
||||||
|
{cloneReferenceImages.length ? (
|
||||||
|
<div className="clone-ai-replicate-preview" aria-hidden="true">
|
||||||
|
{cloneReferenceImages.slice(0, 4).map((item) => (
|
||||||
|
<figure key={item.id}>
|
||||||
|
<img src={item.src} alt="" />
|
||||||
|
<span className="uploaded-image-zoom">
|
||||||
|
<img src={item.src} alt="" />
|
||||||
|
</span>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<label className="clone-ai-replicate-link">
|
||||||
|
<input placeholder="粘贴商品图或详情页链接" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={cloneReferenceInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
onChange={handleCloneReferenceUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-replicate-section">
|
||||||
|
<span className="clone-ai-replicate-title">复刻程度</span>
|
||||||
|
<div className="clone-ai-replicate-levels" role="radiogroup" aria-label="复刻程度">
|
||||||
|
{cloneReplicateLevelOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={cloneReplicateLevel === option.key ? "is-active" : ""}
|
||||||
|
aria-pressed={cloneReplicateLevel === option.key}
|
||||||
|
onClick={() => setCloneReplicateLevel(option.key)}
|
||||||
|
>
|
||||||
|
<strong>{option.title}</strong>
|
||||||
|
<span>{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{cloneOutput === "set" ? (
|
||||||
|
<section className="clone-ai-count-panel" aria-label="套图图片数量">
|
||||||
|
<p>可自由调整各类型图片数量,总数 1-16 张</p>
|
||||||
|
<div className="clone-ai-count-list">
|
||||||
|
{cloneSetCountOptions.map((item) => {
|
||||||
|
const count = cloneSetCounts[item.key];
|
||||||
|
const decrementDisabled = count <= 0 || cloneSetTotal <= minCloneSetTotal;
|
||||||
|
const incrementDisabled = cloneSetTotal >= maxCloneSetTotal;
|
||||||
|
return (
|
||||||
|
<div key={item.key} className="clone-ai-count-row">
|
||||||
|
<div className="clone-ai-count-copy">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span>{item.desc}</span>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-count-stepper" aria-label={`${item.title}数量`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={decrementDisabled}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
startCloneSetCountHold(item.key, -1, decrementDisabled);
|
||||||
|
}}
|
||||||
|
onPointerUp={clearCloneSetCountHold}
|
||||||
|
onPointerLeave={clearCloneSetCountHold}
|
||||||
|
onPointerCancel={clearCloneSetCountHold}
|
||||||
|
onBlur={clearCloneSetCountHold}
|
||||||
|
aria-label={`减少${item.title}`}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<b>{count}</b>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={incrementDisabled}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
startCloneSetCountHold(item.key, 1, incrementDisabled);
|
||||||
|
}}
|
||||||
|
onPointerUp={clearCloneSetCountHold}
|
||||||
|
onPointerLeave={clearCloneSetCountHold}
|
||||||
|
onPointerCancel={clearCloneSetCountHold}
|
||||||
|
onBlur={clearCloneSetCountHold}
|
||||||
|
aria-label={`增加${item.title}`}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{cloneOutput === "detail" ? (
|
||||||
|
<section className="clone-ai-module-panel" aria-label="详情图包含模块">
|
||||||
|
<p>
|
||||||
|
包含模块(多选)
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</p>
|
||||||
|
<div className="clone-ai-module-list">
|
||||||
|
{cloneDetailModules.map((module) => {
|
||||||
|
const isSelected = selectedCloneDetailModules.includes(module.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={module.id}
|
||||||
|
type="button"
|
||||||
|
className={isSelected ? "is-active" : ""}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={() => toggleCloneDetailModule(module.id)}
|
||||||
|
>
|
||||||
|
<strong>{module.title}</strong>
|
||||||
|
<span>{module.desc}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{cloneOutput === "model" ? (
|
||||||
|
<section className="clone-ai-model-panel" aria-label="模特图设置">
|
||||||
|
<div className="clone-ai-model-tabs" role="tablist" aria-label="模特图设置类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cloneModelPanelTab === "scene" ? "is-active" : ""}
|
||||||
|
aria-selected={cloneModelPanelTab === "scene"}
|
||||||
|
onClick={() => setCloneModelPanelTab("scene")}
|
||||||
|
>
|
||||||
|
拍摄场景
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cloneModelPanelTab === "model" ? "is-active" : ""}
|
||||||
|
aria-selected={cloneModelPanelTab === "model"}
|
||||||
|
onClick={() => setCloneModelPanelTab("model")}
|
||||||
|
>
|
||||||
|
模特形象
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-model-scroll">
|
||||||
|
{cloneModelPanelTab === "scene" ? (
|
||||||
|
<div className="clone-ai-model-scenes">
|
||||||
|
<div className="clone-ai-model-scene-grid">
|
||||||
|
{tryOnScenes.map((scene) => {
|
||||||
|
const isSelected = selectedCloneModelScenes.includes(scene);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={scene}
|
||||||
|
type="button"
|
||||||
|
className={isSelected ? "is-active" : ""}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={() => toggleCloneModelScene(scene)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
{scene}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="clone-ai-model-textarea">
|
||||||
|
<strong>或自定义描述场景(可选)</strong>
|
||||||
|
<textarea
|
||||||
|
value={cloneModelCustomScene}
|
||||||
|
onChange={(event) => setCloneModelCustomScene(event.target.value)}
|
||||||
|
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="clone-ai-model-profile">
|
||||||
|
<div className="clone-ai-model-select-grid">
|
||||||
|
{cloneModelSelects.map((item) => {
|
||||||
|
const isOpen = openCloneModelSelect === item.key;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={`clone-ai-model-select${isOpen ? " is-open" : ""}${
|
||||||
|
isOpen && cloneModelSelectDropUp ? " is-drop-up" : ""
|
||||||
|
}`}
|
||||||
|
data-clone-model-select
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isOpen ? "is-open" : ""}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-controls={`clone-model-select-${item.key}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
setOpenCloneBasicSelect(null);
|
||||||
|
if (!isOpen) {
|
||||||
|
event.currentTarget.scrollIntoView({ block: "center", inline: "nearest" });
|
||||||
|
const triggerRect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const scrollRect = event.currentTarget.closest(".clone-ai-model-scroll")?.getBoundingClientRect();
|
||||||
|
const lowerBoundary = Math.min(window.innerHeight, scrollRect?.bottom ?? window.innerHeight);
|
||||||
|
const upperBoundary = Math.max(0, scrollRect?.top ?? 0);
|
||||||
|
const estimatedMenuHeight = Math.min(150, item.options.length * 36 + 12);
|
||||||
|
const belowSpace = lowerBoundary - triggerRect.bottom;
|
||||||
|
const aboveSpace = triggerRect.top - upperBoundary;
|
||||||
|
setCloneModelSelectDropUp(belowSpace < estimatedMenuHeight && aboveSpace > belowSpace);
|
||||||
|
} else {
|
||||||
|
setCloneModelSelectDropUp(false);
|
||||||
|
}
|
||||||
|
setOpenCloneModelSelect(isOpen ? null : item.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{item.value}</strong>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{isOpen ? (
|
||||||
|
<div id={`clone-model-select-${item.key}`} className="clone-ai-model-select__menu" role="listbox">
|
||||||
|
{item.options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={item.value === option ? "is-active" : ""}
|
||||||
|
role="option"
|
||||||
|
aria-selected={item.value === option}
|
||||||
|
onClick={() => {
|
||||||
|
item.onChange(option);
|
||||||
|
setOpenCloneModelSelect(null);
|
||||||
|
setCloneModelSelectDropUp(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label className="clone-ai-model-textarea">
|
||||||
|
<strong>外貌细节(可选)</strong>
|
||||||
|
<textarea
|
||||||
|
value={cloneModelAppearance}
|
||||||
|
onChange={(event) => setCloneModelAppearance(event.target.value)}
|
||||||
|
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{cloneOutput === "video" ? (
|
||||||
|
<section className="clone-ai-video-panel" aria-label="短视频设置">
|
||||||
|
<div className="clone-ai-video-section">
|
||||||
|
<span className="clone-ai-video-title">视频画质</span>
|
||||||
|
<div className="clone-ai-video-options">
|
||||||
|
{cloneVideoQualityOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={cloneVideoQuality === option.key ? "is-active" : ""}
|
||||||
|
aria-pressed={cloneVideoQuality === option.key}
|
||||||
|
onClick={() => setCloneVideoQuality(option.key)}
|
||||||
|
>
|
||||||
|
<strong>{option.label}</strong>
|
||||||
|
<span>{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-video-section">
|
||||||
|
<div className="clone-ai-video-title-row">
|
||||||
|
<span className="clone-ai-video-title">时间设置</span>
|
||||||
|
<strong>{cloneVideoDuration}秒</strong>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-duration-control" style={cloneVideoDurationStyle}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={cloneVideoDurationMin}
|
||||||
|
max={cloneVideoDurationMax}
|
||||||
|
step={1}
|
||||||
|
value={cloneVideoDuration}
|
||||||
|
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
|
||||||
|
aria-label="短视频时长"
|
||||||
|
/>
|
||||||
|
<div className="clone-ai-duration-scale" aria-hidden="true">
|
||||||
|
<span>5秒</span>
|
||||||
|
<span>10秒</span>
|
||||||
|
<span>15秒</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`clone-ai-video-smart${cloneVideoSmart ? " is-on" : ""}`}
|
||||||
|
aria-pressed={cloneVideoSmart}
|
||||||
|
onClick={() => setCloneVideoSmart((current) => !current)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>智能选择</strong>
|
||||||
|
<em>根据平台、商品图和尺寸自动匹配推荐参数</em>
|
||||||
|
</span>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{cloneOutput === "video-outfit" ? (
|
||||||
|
<section className="clone-ai-video-panel" aria-label="视频换装">
|
||||||
|
<div className="clone-ai-video-section">
|
||||||
|
<span className="clone-ai-video-title">上传原始视频</span>
|
||||||
|
<div className="clone-ai-video-outfit-upload">
|
||||||
|
<input
|
||||||
|
ref={videoOutfitVideoRef}
|
||||||
|
type="file"
|
||||||
|
accept="video/*"
|
||||||
|
onChange={handleVideoOutfitVideoChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
|
||||||
|
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
|
||||||
|
</button>
|
||||||
|
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info">已选择视频</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="clone-ai-video-section">
|
||||||
|
<span className="clone-ai-video-title">上传参考图(素材/服装)</span>
|
||||||
|
<div className="clone-ai-video-outfit-upload">
|
||||||
|
<input
|
||||||
|
ref={videoOutfitRefRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleVideoOutfitRefChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
|
||||||
|
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
|
||||||
|
</button>
|
||||||
|
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info">已选择参考图</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button type="button" className="clone-ai-generate" disabled={!canGenerate || cloneOutput === "video"} onClick={status === "failed" && lastFailedActionRef.current ? lastFailedActionRef.current : handleGenerate} style={cloneOutput === "video" ? { display: "none" } : undefined}>
|
||||||
|
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
|
||||||
|
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||||
|
import type { ChangeEvent, RefObject } from "react";
|
||||||
|
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||||
|
|
||||||
|
interface EcommerceDetailPanelProps {
|
||||||
|
detailInputRef: RefObject<HTMLInputElement>;
|
||||||
|
detailProductImages: Array<{ id: string; src: string; name: string }>;
|
||||||
|
detailPlatform: string;
|
||||||
|
detailMarket: string;
|
||||||
|
detailLanguage: string;
|
||||||
|
detailType: string;
|
||||||
|
detailRequirement: string;
|
||||||
|
selectedDetailModules: string[];
|
||||||
|
detailStatus: string;
|
||||||
|
canGenerateDetail: boolean;
|
||||||
|
detailPrimaryLabel: string;
|
||||||
|
platformOptions: string[];
|
||||||
|
marketOptions: string[];
|
||||||
|
detailLanguageOptions: string[];
|
||||||
|
detailTypeOptions: string[];
|
||||||
|
detailModules: Array<{ id: string; title: string; desc: string }>;
|
||||||
|
handleDetailUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleDetailPlatformChange: (value: string) => void;
|
||||||
|
handleDetailMarketChange: (value: string) => void;
|
||||||
|
setDetailLanguage: (value: string) => void;
|
||||||
|
setDetailType: (value: string) => void;
|
||||||
|
setDetailRequirement: (value: string) => void;
|
||||||
|
handleDetailAiWrite: () => void;
|
||||||
|
toggleDetailModule: (id: string) => void;
|
||||||
|
handleDetailGenerate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EcommerceDetailPanel({
|
||||||
|
detailInputRef,
|
||||||
|
detailProductImages,
|
||||||
|
detailPlatform,
|
||||||
|
detailMarket,
|
||||||
|
detailLanguage,
|
||||||
|
detailType,
|
||||||
|
detailRequirement,
|
||||||
|
selectedDetailModules,
|
||||||
|
detailStatus,
|
||||||
|
canGenerateDetail,
|
||||||
|
detailPrimaryLabel,
|
||||||
|
platformOptions,
|
||||||
|
marketOptions,
|
||||||
|
detailLanguageOptions,
|
||||||
|
detailTypeOptions,
|
||||||
|
detailModules,
|
||||||
|
handleDetailUpload,
|
||||||
|
handleDetailPlatformChange,
|
||||||
|
handleDetailMarketChange,
|
||||||
|
setDetailLanguage,
|
||||||
|
setDetailType,
|
||||||
|
setDetailRequirement,
|
||||||
|
handleDetailAiWrite,
|
||||||
|
toggleDetailModule,
|
||||||
|
handleDetailGenerate,
|
||||||
|
}: EcommerceDetailPanelProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="product-clone-panel__scroll">
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>
|
||||||
|
商品原图
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</h2>
|
||||||
|
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
|
||||||
|
<strong>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
上传图片
|
||||||
|
</strong>
|
||||||
|
<span>同一产品,最多3张。</span>
|
||||||
|
</button>
|
||||||
|
<input ref={detailInputRef} type="file" accept="image/*" multiple onChange={handleDetailUpload} />
|
||||||
|
{detailProductImages.length ? (
|
||||||
|
<div className="product-clone-thumb-row" aria-label="已上传商品原图">
|
||||||
|
{detailProductImages.map((item) => (
|
||||||
|
<figure key={item.id} className="product-clone-uploaded-thumb">
|
||||||
|
<img src={item.src} alt={item.name} />
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={item.src} alt="" />
|
||||||
|
</span>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>生成设置</h2>
|
||||||
|
<div className="product-detail-settings-grid">
|
||||||
|
<select value={detailPlatform} onChange={(event) => handleDetailPlatformChange(event.target.value)}>
|
||||||
|
{platformOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={detailMarket} onChange={(event) => handleDetailMarketChange(event.target.value)}>
|
||||||
|
{marketOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={detailLanguage} onChange={(event) => setDetailLanguage(event.target.value)}>
|
||||||
|
{detailLanguageOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={detailType} onChange={(event) => setDetailType(event.target.value)}>
|
||||||
|
{detailTypeOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field product-detail-requirement">
|
||||||
|
<h2>
|
||||||
|
商品卖点&要求
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
handleDetailAiWrite();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AI 帮写
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<textarea
|
||||||
|
value={detailRequirement}
|
||||||
|
onChange={(event) => setDetailRequirement(event.target.value)}
|
||||||
|
placeholder={"建议包含以下信息生成更精准:\n1.产品名称\n2.核心卖点\n3.适用人群\n4.期望场景\n5.具体参数"}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>
|
||||||
|
包含模块(多选)
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</h2>
|
||||||
|
<div className="product-detail-module-grid">
|
||||||
|
{detailModules.map((module) => (
|
||||||
|
<button
|
||||||
|
key={module.id}
|
||||||
|
type="button"
|
||||||
|
className={selectedDetailModules.includes(module.id) ? "is-active" : ""}
|
||||||
|
onClick={() => toggleDetailModule(module.id)}
|
||||||
|
>
|
||||||
|
<strong>{module.title}</strong>
|
||||||
|
<span>{module.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="product-clone-panel__footer">
|
||||||
|
{detailStatus === "generating" ? <EcommerceProgressBar status="generating" label="A+详情页" /> : null}
|
||||||
|
<button type="button" className="product-clone-primary" disabled={!canGenerateDetail} onClick={handleDetailGenerate}>
|
||||||
|
{detailStatus === "generating" ? <LoadingOutlined /> : null}
|
||||||
|
{detailPrimaryLabel}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
|
||||||
|
import type { ChangeEvent, DragEvent, RefObject } from "react";
|
||||||
|
|
||||||
|
interface EcommerceSetPanelProps {
|
||||||
|
setInputRef: RefObject<HTMLInputElement>;
|
||||||
|
setImages: Array<{ id: string; src: string; name: string }>;
|
||||||
|
isSetUploadDragging: boolean;
|
||||||
|
productSetOutputOptions: Array<{ key: string; label: string }>;
|
||||||
|
productSetOutput: string;
|
||||||
|
platformOptions: string[];
|
||||||
|
marketOptions: string[];
|
||||||
|
productSetLanguageOptions: string[];
|
||||||
|
productSetRatioOptions: string[];
|
||||||
|
productSetPlatform: string;
|
||||||
|
productSetMarket: string;
|
||||||
|
productSetLanguage: string;
|
||||||
|
productSetRatio: string;
|
||||||
|
setIsSetUploadDragging: (value: boolean) => void;
|
||||||
|
handleSetDrop: (event: DragEvent<HTMLElement>) => void;
|
||||||
|
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
removeSetImage: (id: string) => void;
|
||||||
|
handleProductSetOutputChange: (value: string) => void;
|
||||||
|
handleProductSetPlatformChange: (value: string) => void;
|
||||||
|
handleProductSetMarketChange: (value: string) => void;
|
||||||
|
setProductSetLanguage: (value: string) => void;
|
||||||
|
setProductSetRatio: (value: string) => void;
|
||||||
|
formatRatioDisplayValue: (value: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EcommerceSetPanel({
|
||||||
|
setInputRef,
|
||||||
|
setImages,
|
||||||
|
isSetUploadDragging,
|
||||||
|
productSetOutputOptions,
|
||||||
|
productSetOutput,
|
||||||
|
platformOptions,
|
||||||
|
marketOptions,
|
||||||
|
productSetLanguageOptions,
|
||||||
|
productSetRatioOptions,
|
||||||
|
productSetPlatform,
|
||||||
|
productSetMarket,
|
||||||
|
productSetLanguage,
|
||||||
|
productSetRatio,
|
||||||
|
setIsSetUploadDragging,
|
||||||
|
handleSetDrop,
|
||||||
|
handleSetUpload,
|
||||||
|
removeSetImage,
|
||||||
|
handleProductSetOutputChange,
|
||||||
|
handleProductSetPlatformChange,
|
||||||
|
handleProductSetMarketChange,
|
||||||
|
setProductSetLanguage,
|
||||||
|
setProductSetRatio,
|
||||||
|
formatRatioDisplayValue,
|
||||||
|
}: EcommerceSetPanelProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="product-clone-panel__scroll">
|
||||||
|
<section className="product-clone-field product-set-upload-section">
|
||||||
|
<h2>
|
||||||
|
上传商品原图
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`product-clone-upload-zone product-set-upload${isSetUploadDragging ? " is-dragging" : ""}`}
|
||||||
|
onClick={() => setInputRef.current?.click()}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSetUploadDragging(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDragLeave={() => setIsSetUploadDragging(false)}
|
||||||
|
onDrop={handleSetDrop}
|
||||||
|
>
|
||||||
|
<span className="product-set-upload-icon">
|
||||||
|
<FileImageOutlined />
|
||||||
|
</span>
|
||||||
|
<span className="product-set-upload-title">拖拽或点击上传</span>
|
||||||
|
<strong>
|
||||||
|
<span aria-hidden="true">+</span>
|
||||||
|
上传图片
|
||||||
|
</strong>
|
||||||
|
<span className="product-set-upload-note">同一产品,最多 3 张</span>
|
||||||
|
</button>
|
||||||
|
<input ref={setInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={handleSetUpload} />
|
||||||
|
{setImages.length ? (
|
||||||
|
<div className="product-clone-thumb-row product-set-thumb-row" aria-label="已上传商品原图">
|
||||||
|
{setImages.map((item) => (
|
||||||
|
<figure key={item.id} className="product-set-thumb">
|
||||||
|
<img src={item.src} alt={item.name} />
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={item.src} alt="" />
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => removeSetImage(item.id)} aria-label={`删除${item.name}`}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field product-set-settings-section">
|
||||||
|
<h2>
|
||||||
|
生成设置
|
||||||
|
<SettingOutlined />
|
||||||
|
</h2>
|
||||||
|
<div className="product-set-setting-block">
|
||||||
|
<span className="product-set-setting-title">生成内容</span>
|
||||||
|
<div className="product-set-output-grid" role="radiogroup" aria-label="生成内容">
|
||||||
|
{productSetOutputOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={productSetOutput === option.key ? "is-active" : ""}
|
||||||
|
aria-pressed={productSetOutput === option.key}
|
||||||
|
onClick={() => handleProductSetOutputChange(option.key)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="product-set-setting-block">
|
||||||
|
<span className="product-set-setting-title">基础设置</span>
|
||||||
|
<div className="product-set-field-grid">
|
||||||
|
<label>
|
||||||
|
<span>平台</span>
|
||||||
|
<select value={productSetPlatform} onChange={(event) => handleProductSetPlatformChange(event.target.value)}>
|
||||||
|
{platformOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>国家</span>
|
||||||
|
<select value={productSetMarket} onChange={(event) => handleProductSetMarketChange(event.target.value)}>
|
||||||
|
{marketOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>语言</span>
|
||||||
|
<select value={productSetLanguage} onChange={(event) => setProductSetLanguage(event.target.value)}>
|
||||||
|
{productSetLanguageOptions.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>尺寸/比例</span>
|
||||||
|
<select
|
||||||
|
value={productSetRatio}
|
||||||
|
onChange={(event) => setProductSetRatio(event.target.value)}
|
||||||
|
disabled={productSetRatioOptions.length <= 1}
|
||||||
|
>
|
||||||
|
{productSetRatioOptions.map((item) => (
|
||||||
|
<option key={item} value={item}>{formatRatioDisplayValue(item)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||||
|
import type { ChangeEvent, RefObject } from "react";
|
||||||
|
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||||
|
|
||||||
|
interface EcommerceTryOnPanelProps {
|
||||||
|
garmentInputRef: RefObject<HTMLInputElement>;
|
||||||
|
garmentImages: Array<{ id: string; src: string; name: string }>;
|
||||||
|
modelSource: string;
|
||||||
|
modelGender: string;
|
||||||
|
modelAge: string;
|
||||||
|
modelEthnicity: string;
|
||||||
|
modelBody: string;
|
||||||
|
appearance: string;
|
||||||
|
selectedScenes: string[];
|
||||||
|
customScene: string;
|
||||||
|
smartScene: boolean;
|
||||||
|
tryOnRatio: string;
|
||||||
|
tryOnStatus: string;
|
||||||
|
canGenerateTryOn: boolean;
|
||||||
|
tryOnPrimaryLabel: string;
|
||||||
|
tryOnModelOptions: { gender: string[]; age: string[]; ethnicity: string[]; body: string[] };
|
||||||
|
tryOnAssets: { modelWoman: string; modelMan: string; modelAsian: string };
|
||||||
|
tryOnScenes: string[];
|
||||||
|
tryOnRatioOptions: string[];
|
||||||
|
handleGarmentUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
setModelSource: (value: "ai" | "library") => void;
|
||||||
|
setModelGender: (value: string) => void;
|
||||||
|
setModelAge: (value: string) => void;
|
||||||
|
setModelEthnicity: (value: string) => void;
|
||||||
|
setModelBody: (value: string) => void;
|
||||||
|
setAppearance: (value: string) => void;
|
||||||
|
handleGenerateModel: () => void;
|
||||||
|
toggleScene: (scene: string) => void;
|
||||||
|
setCustomScene: (value: string) => void;
|
||||||
|
setSmartScene: (updater: (current: boolean) => boolean) => void;
|
||||||
|
setTryOnRatio: (value: string) => void;
|
||||||
|
handleTryOnGenerate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EcommerceTryOnPanel({
|
||||||
|
garmentInputRef,
|
||||||
|
garmentImages,
|
||||||
|
modelSource,
|
||||||
|
modelGender,
|
||||||
|
modelAge,
|
||||||
|
modelEthnicity,
|
||||||
|
modelBody,
|
||||||
|
appearance,
|
||||||
|
selectedScenes,
|
||||||
|
customScene,
|
||||||
|
smartScene,
|
||||||
|
tryOnRatio,
|
||||||
|
tryOnStatus,
|
||||||
|
canGenerateTryOn,
|
||||||
|
tryOnPrimaryLabel,
|
||||||
|
tryOnModelOptions,
|
||||||
|
tryOnAssets,
|
||||||
|
tryOnScenes,
|
||||||
|
tryOnRatioOptions,
|
||||||
|
handleGarmentUpload,
|
||||||
|
setModelSource,
|
||||||
|
setModelGender,
|
||||||
|
setModelAge,
|
||||||
|
setModelEthnicity,
|
||||||
|
setModelBody,
|
||||||
|
setAppearance,
|
||||||
|
handleGenerateModel,
|
||||||
|
toggleScene,
|
||||||
|
setCustomScene,
|
||||||
|
setSmartScene,
|
||||||
|
setTryOnRatio,
|
||||||
|
handleTryOnGenerate,
|
||||||
|
}: EcommerceTryOnPanelProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="product-clone-panel__scroll">
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>服装图片</h2>
|
||||||
|
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
|
||||||
|
<strong>
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
服装图片
|
||||||
|
</strong>
|
||||||
|
<span>整套搭配或同一件服装不同角度图,最多5张。</span>
|
||||||
|
</button>
|
||||||
|
<input ref={garmentInputRef} type="file" accept="image/*" multiple onChange={handleGarmentUpload} />
|
||||||
|
{garmentImages.length ? (
|
||||||
|
<div className="product-clone-thumb-row product-try-on-thumb-row" aria-label="已上传服装图片">
|
||||||
|
{garmentImages.map((item) => (
|
||||||
|
<figure key={item.id} className="product-clone-uploaded-thumb">
|
||||||
|
<img src={item.src} alt={item.name} />
|
||||||
|
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||||
|
<img src={item.src} alt="" />
|
||||||
|
</span>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>模特形象</h2>
|
||||||
|
<div className="product-clone-segment" role="tablist" aria-label="模特来源">
|
||||||
|
<button type="button" className={modelSource === "ai" ? "is-active" : ""} onClick={() => setModelSource("ai")}>
|
||||||
|
AI 生成
|
||||||
|
</button>
|
||||||
|
<button type="button" className={modelSource === "library" ? "is-active" : ""} onClick={() => setModelSource("library")}>
|
||||||
|
模特库
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{modelSource === "ai" ? (
|
||||||
|
<>
|
||||||
|
<div className="product-clone-model-grid">
|
||||||
|
<select value={modelGender} onChange={(event) => setModelGender(event.target.value)}>
|
||||||
|
{tryOnModelOptions.gender.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={modelAge} onChange={(event) => setModelAge(event.target.value)}>
|
||||||
|
{tryOnModelOptions.age.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={modelEthnicity} onChange={(event) => setModelEthnicity(event.target.value)}>
|
||||||
|
{tryOnModelOptions.ethnicity.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={modelBody} onChange={(event) => setModelBody(event.target.value)}>
|
||||||
|
{tryOnModelOptions.body.map((item) => (
|
||||||
|
<option key={item}>{item}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="product-try-on-textarea-label">
|
||||||
|
<span>外貌细节(可选)</span>
|
||||||
|
<textarea
|
||||||
|
value={appearance}
|
||||||
|
onChange={(event) => setAppearance(event.target.value)}
|
||||||
|
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="product-clone-model-button" onClick={handleGenerateModel} disabled={tryOnStatus === "modeling"}>
|
||||||
|
{tryOnStatus === "modeling" ? <LoadingOutlined /> : null}
|
||||||
|
{tryOnStatus === "modeling" ? "生成中..." : "生成基准模特"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="product-try-on-library" aria-label="模特库">
|
||||||
|
{[tryOnAssets.modelWoman, tryOnAssets.modelMan, tryOnAssets.modelAsian].map((src, index) => (
|
||||||
|
<button key={src} type="button" className={index === 0 ? "is-active" : ""}>
|
||||||
|
<img src={src} alt={`模特 ${index + 1}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>拍摄场景</h2>
|
||||||
|
<div className="product-clone-scene-grid">
|
||||||
|
{tryOnScenes.map((scene) => (
|
||||||
|
<button
|
||||||
|
key={scene}
|
||||||
|
type="button"
|
||||||
|
className={selectedScenes.includes(scene) ? "is-active" : ""}
|
||||||
|
onClick={() => toggleScene(scene)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
{scene}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<label className="product-clone-field product-try-on-scene-field">
|
||||||
|
<h2>或自定义描述场景(可选)</h2>
|
||||||
|
<textarea
|
||||||
|
value={customScene}
|
||||||
|
onChange={(event) => setCustomScene(event.target.value)}
|
||||||
|
placeholder="描述你想要的场景:如秋季枫叶小径、暖色调午后阳光、模特倚靠树干..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<button type="button" className="product-clone-switch-row" onClick={() => setSmartScene((current) => !current)}>
|
||||||
|
<span>
|
||||||
|
<strong>智能推荐场景</strong>
|
||||||
|
<em>根据服装自动匹配最佳场景</em>
|
||||||
|
</span>
|
||||||
|
<span className={`product-clone-switch${smartScene ? " is-on" : ""}`} role="switch" aria-checked={smartScene}>
|
||||||
|
<span />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="product-clone-field">
|
||||||
|
<h2>图片比例</h2>
|
||||||
|
<div className="product-clone-ratio-row">
|
||||||
|
{tryOnRatioOptions.map((item) => (
|
||||||
|
<button key={item} type="button" className={tryOnRatio === item ? "is-active" : ""} onClick={() => setTryOnRatio(item)}>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="product-clone-panel__footer">
|
||||||
|
{tryOnStatus === "generating" ? <EcommerceProgressBar status="generating" label="服饰穿戴图" /> : null}
|
||||||
|
<button type="button" className="product-clone-primary" disabled={!canGenerateTryOn} onClick={handleTryOnGenerate}>
|
||||||
|
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
|
||||||
|
{tryOnPrimaryLabel}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -142,6 +142,8 @@ function TokenUsagePage({
|
|||||||
onSelectView,
|
onSelectView,
|
||||||
}: TokenUsagePageProps) {
|
}: TokenUsagePageProps) {
|
||||||
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
||||||
|
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
|
||||||
|
const [enterpriseUsageError, setEnterpriseUsageError] = useState<string | null>(null);
|
||||||
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
|
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
|
||||||
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||||
|
|
||||||
@@ -152,10 +154,15 @@ function TokenUsagePage({
|
|||||||
setEnterpriseUsage(null);
|
setEnterpriseUsage(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setEnterpriseUsageLoading(true);
|
||||||
|
setEnterpriseUsageError(null);
|
||||||
try {
|
try {
|
||||||
setEnterpriseUsage(await loader());
|
setEnterpriseUsage(await loader());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setEnterpriseUsage(null);
|
setEnterpriseUsage(null);
|
||||||
|
setEnterpriseUsageError(error instanceof Error ? error.message : "加载失败");
|
||||||
|
} finally {
|
||||||
|
setEnterpriseUsageLoading(false);
|
||||||
}
|
}
|
||||||
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type GenStatus = "idle" | "ready" | "generating" | "done" | "failed";
|
||||||
|
|
||||||
|
export interface UseGenerationStatusReturn {
|
||||||
|
status: GenStatus;
|
||||||
|
error: string | null;
|
||||||
|
abortRef: { current: boolean };
|
||||||
|
start: () => void;
|
||||||
|
succeed: () => void;
|
||||||
|
fail: (msg: string) => void;
|
||||||
|
reset: () => void;
|
||||||
|
cancel: () => void;
|
||||||
|
isGenerating: boolean;
|
||||||
|
isFailed: boolean;
|
||||||
|
isIdle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerationStatus(): UseGenerationStatusReturn {
|
||||||
|
const [status, setStatus] = useState<GenStatus>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const abortRef = useRef({ current: false });
|
||||||
|
|
||||||
|
const start = useCallback(() => {
|
||||||
|
setStatus("generating");
|
||||||
|
setError(null);
|
||||||
|
abortRef.current = { current: false };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const succeed = useCallback(() => setStatus("done"), []);
|
||||||
|
const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []);
|
||||||
|
const reset = useCallback(() => { setStatus("idle"); setError(null); }, []);
|
||||||
|
const cancel = useCallback(() => { abortRef.current.current = true; }, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status, error, abortRef, start, succeed, fail, reset, cancel,
|
||||||
|
isGenerating: status === "generating",
|
||||||
|
isFailed: status === "failed",
|
||||||
|
isIdle: status === "idle",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
|
import type { GenerationQueueItem } from "../stores/useGenerationStore";
|
||||||
|
import { useGenerationStore } from "../stores/useGenerationStore";
|
||||||
|
import {
|
||||||
|
startBackgroundPolling,
|
||||||
|
subscribeToTaskUpdates,
|
||||||
|
} from "../services/backgroundTaskRunner";
|
||||||
|
|
||||||
|
interface UseGenerationTasksOptions {
|
||||||
|
sourceView: string;
|
||||||
|
autoResume?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerationTasks(options: UseGenerationTasksOptions) {
|
||||||
|
const { sourceView, autoResume = true } = options;
|
||||||
|
const store = useGenerationStore();
|
||||||
|
const pollingStartedRef = useRef(false);
|
||||||
|
|
||||||
|
// ── Auto-resume: re-subscribe to running tasks on mount ────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoResume || pollingStartedRef.current) return;
|
||||||
|
pollingStartedRef.current = true;
|
||||||
|
|
||||||
|
const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView);
|
||||||
|
if (active.length > 0) {
|
||||||
|
startBackgroundPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pollingStartedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [autoResume, sourceView, store]);
|
||||||
|
|
||||||
|
// ── Subscribe to live updates ───────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeToTaskUpdates((updated) => {
|
||||||
|
store.updateTask(updated.id, updated);
|
||||||
|
});
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
// ── View-scoped computed lists ──────────────────────────
|
||||||
|
const myTasks = useMemo(
|
||||||
|
() => store.queue.filter((t) => t.sourceView === sourceView),
|
||||||
|
[store.queue, sourceView],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTasks = useMemo(
|
||||||
|
() => myTasks.filter((t) => t.status === "running" || t.status === "pending"),
|
||||||
|
[myTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedTasks = useMemo(
|
||||||
|
() => myTasks.filter((t) => t.status === "completed"),
|
||||||
|
[myTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedTasks = useMemo(
|
||||||
|
() => myTasks.filter((t) => t.status === "failed"),
|
||||||
|
[myTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Actions ─────────────────────────────────────────────
|
||||||
|
const submitTask = useCallback(
|
||||||
|
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
|
||||||
|
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
store.addTask({ ...task, id, createdAt: Date.now() });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTask = useCallback(
|
||||||
|
(id: string, patch: Partial<GenerationQueueItem>) => {
|
||||||
|
store.updateTask(id, patch);
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markCompleted = useCallback(
|
||||||
|
(id: string, resultUrl: string) => {
|
||||||
|
store.updateTask(id, { status: "completed", progress: 100, resultUrl });
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const markFailed = useCallback(
|
||||||
|
(id: string, error: string) => {
|
||||||
|
store.updateTask(id, { status: "failed", error });
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryTask = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const task = store.queue.find((t) => t.id === id);
|
||||||
|
if (task) {
|
||||||
|
store.updateTask(id, { status: "pending", progress: 0, error: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: myTasks,
|
||||||
|
activeTasks,
|
||||||
|
completedTasks,
|
||||||
|
failedTasks,
|
||||||
|
submitTask,
|
||||||
|
updateTask,
|
||||||
|
markCompleted,
|
||||||
|
markFailed,
|
||||||
|
retryTask,
|
||||||
|
hasActiveTasks: activeTasks.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
||||||
|
import { aiGenerationClient } from "../api/aiGenerationClient";
|
||||||
|
|
||||||
|
type PollCallback = (item: GenerationQueueItem) => void;
|
||||||
|
|
||||||
|
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
|
const pollCallbacks = new Set<PollCallback>();
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 3000;
|
||||||
|
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
|
||||||
|
|
||||||
|
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
||||||
|
pollCallbacks.add(callback);
|
||||||
|
return () => { pollCallbacks.delete(callback); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyCallbacks(item: GenerationQueueItem): void {
|
||||||
|
pollCallbacks.forEach((cb) => cb(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
|
||||||
|
const key = `poll-${item.id}`;
|
||||||
|
if (activePollers.has(key)) return;
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||||
|
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
|
||||||
|
cleanupPoll(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptsRef.current++;
|
||||||
|
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
|
||||||
|
useGenerationStore.getState().updateTask(item.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "任务超时,请重新提交",
|
||||||
|
});
|
||||||
|
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
|
||||||
|
cleanupPoll(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
|
||||||
|
const patch: Partial<GenerationQueueItem> = {
|
||||||
|
progress: status.progress,
|
||||||
|
resultUrl: status.resultUrl || current.resultUrl,
|
||||||
|
error: status.error || current.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.status === "completed") {
|
||||||
|
patch.status = "completed";
|
||||||
|
useGenerationStore.getState().updateTask(item.id, patch);
|
||||||
|
notifyCallbacks({ ...item, ...patch, status: "completed" });
|
||||||
|
cleanupPoll(key);
|
||||||
|
} else if (status.status === "failed" || status.status === "cancelled") {
|
||||||
|
patch.status = "failed";
|
||||||
|
useGenerationStore.getState().updateTask(item.id, patch);
|
||||||
|
notifyCallbacks({ ...item, ...patch, status: "failed" });
|
||||||
|
cleanupPoll(key);
|
||||||
|
} else {
|
||||||
|
patch.status = "running";
|
||||||
|
useGenerationStore.getState().updateTask(item.id, patch);
|
||||||
|
notifyCallbacks({ ...item, ...patch, status: "running" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error during poll — keep trying
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
|
||||||
|
activePollers.set(key, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPoll(key: string): void {
|
||||||
|
const interval = activePollers.get(key);
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
activePollers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBackgroundPolling(): void {
|
||||||
|
const tasks = useGenerationStore.getState().getRunningTasks();
|
||||||
|
const attemptsMap = new Map<string, { current: number }>();
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.taskId) {
|
||||||
|
if (!attemptsMap.has(task.id)) {
|
||||||
|
attemptsMap.set(task.id, { current: 0 });
|
||||||
|
}
|
||||||
|
pollTask(task, attemptsMap.get(task.id)!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeTaskPolling(taskId: string, storeId: string): void {
|
||||||
|
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
|
||||||
|
if (task && task.status !== "completed" && task.status !== "failed") {
|
||||||
|
pollTask(task, { current: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopAllPolling(): void {
|
||||||
|
activePollers.forEach((interval) => clearInterval(interval));
|
||||||
|
activePollers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recovery on page load ──────────────────────────
|
||||||
|
export function recoverAndResumeTasks(): void {
|
||||||
|
const pendingTasks = useGenerationStore.getState().getRunningTasks();
|
||||||
|
if (!pendingTasks.length) return;
|
||||||
|
|
||||||
|
pendingTasks.forEach((task) => {
|
||||||
|
if (task.taskId) {
|
||||||
|
// Mark as pending so the workbench/ecommerce can re-submit to polling
|
||||||
|
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
|
||||||
|
} else {
|
||||||
|
// No taskId means it was queued but never submitted — mark failed
|
||||||
|
useGenerationStore.getState().updateTask(task.id, {
|
||||||
|
status: "failed",
|
||||||
|
error: "页面刷新后任务丢失,请重新提交",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start polling recovered tasks
|
||||||
|
setTimeout(() => startBackgroundPolling(), 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { WebGenerationPreviewTask } from "../types";
|
||||||
|
|
||||||
|
export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||||
|
|
||||||
|
export interface GenerationQueueItem {
|
||||||
|
id: string;
|
||||||
|
taskId?: string;
|
||||||
|
title: string;
|
||||||
|
type: "image" | "video" | "agent" | "digital-human" | "character-mix" | "ecommerce-video";
|
||||||
|
status: QueueItemStatus;
|
||||||
|
progress: number;
|
||||||
|
prompt: string;
|
||||||
|
createdAt: number;
|
||||||
|
sourceView: string; // which page created this: "ecommerce", "workbench", "canvas", "agent"
|
||||||
|
resultUrl?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersistedQueueSnapshot {
|
||||||
|
version: 1;
|
||||||
|
items: GenerationQueueItem[];
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "omniai:generation-queue";
|
||||||
|
const MAX_ITEMS = 80;
|
||||||
|
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||||
|
|
||||||
|
function loadPersistedQueue(): GenerationQueueItem[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const snapshot = JSON.parse(raw) as PersistedQueueSnapshot;
|
||||||
|
if (Date.now() - (snapshot.savedAt || 0) > STALE_MS) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return snapshot.items.filter(
|
||||||
|
(item) => item.status === "pending" || item.status === "running",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistQueue(items: GenerationQueueItem[]): void {
|
||||||
|
try {
|
||||||
|
const snapshot: PersistedQueueSnapshot = { version: 1, items, savedAt: Date.now() };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
|
} catch { /* quota exceeded */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerationStoreState {
|
||||||
|
queue: GenerationQueueItem[];
|
||||||
|
addTask: (item: GenerationQueueItem) => void;
|
||||||
|
updateTask: (id: string, patch: Partial<GenerationQueueItem>) => void;
|
||||||
|
removeTask: (id: string) => void;
|
||||||
|
getRunningTasks: () => GenerationQueueItem[];
|
||||||
|
getPendingTasks: () => GenerationQueueItem[];
|
||||||
|
getTasksByView: (sourceView: string) => GenerationQueueItem[];
|
||||||
|
clearTerminal: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashUserId(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("omniai-web-session");
|
||||||
|
if (!raw) return "anon";
|
||||||
|
const parsed = JSON.parse(raw) as { user?: { id?: number | string } };
|
||||||
|
return String(parsed?.user?.id || "anon");
|
||||||
|
} catch {
|
||||||
|
return "anon";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialQueue = loadPersistedQueue();
|
||||||
|
|
||||||
|
export const useGenerationStore = create<GenerationStoreState>((set, get) => ({
|
||||||
|
queue: initialQueue,
|
||||||
|
|
||||||
|
addTask: (item) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = [item, ...state.queue].slice(0, MAX_ITEMS);
|
||||||
|
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTask: (id, patch) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.queue.map((item) =>
|
||||||
|
item.id === id ? { ...item, ...patch } : item,
|
||||||
|
);
|
||||||
|
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTask: (id) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.queue.filter((item) => item.id !== id);
|
||||||
|
persistQueue(next.filter((i) => i.status === "pending" || i.status === "running"));
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"),
|
||||||
|
getPendingTasks: () => get().queue.filter((i) => i.status === "pending"),
|
||||||
|
getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView),
|
||||||
|
|
||||||
|
clearTerminal: () => {
|
||||||
|
set((state) => {
|
||||||
|
const next = state.queue.filter(
|
||||||
|
(i) => i.status === "pending" || i.status === "running",
|
||||||
|
);
|
||||||
|
persistQueue(next);
|
||||||
|
return { queue: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -65,6 +65,13 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapse when empty (e.g. KeepAlive pages rendered outside PageTransition) */
|
||||||
|
.page-transition-wrap:empty {
|
||||||
|
height: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* page-motion--exit moved to page-transition.css */
|
/* page-motion--exit moved to page-transition.css */
|
||||||
|
|
||||||
.page-loading-center {
|
.page-loading-center {
|
||||||
|
|||||||
@@ -180,6 +180,9 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #101318;
|
background: #101318;
|
||||||
padding: 26px;
|
padding: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ecom-video-flow-map {
|
.ecom-video-flow-map {
|
||||||
@@ -499,6 +502,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: #697486;
|
color: #697486;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -708,4 +712,382 @@
|
|||||||
.ecom-video-flow-node--scene {
|
.ecom-video-flow-node--scene {
|
||||||
width: 118px;
|
width: 118px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__trunk {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════
|
||||||
|
Tree Layout — 分支树状流程图 (参考图风格)
|
||||||
|
原图 → 分支连接线 → [分镜文本 → 分镜图 → 分镜视频] × N
|
||||||
|
═══════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.ecom-video-tree {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Source node ── */
|
||||||
|
.ecom-video-tree__source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1.5px solid #2c3038;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #171c22;
|
||||||
|
transition: border-color 280ms ease, box-shadow 280ms ease, transform 280ms ease;
|
||||||
|
animation: ecom-tree-node-in 420ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--source {
|
||||||
|
width: 150px;
|
||||||
|
height: 190px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-color: #1c4d3a;
|
||||||
|
background: #162820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--source img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__label {
|
||||||
|
color: #a0b0aa;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text node (分镜文本) ── */
|
||||||
|
.ecom-video-tree-node--text {
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 150px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
cursor: default;
|
||||||
|
border-color: #2a3d30;
|
||||||
|
background: #131d1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--text.is-completed {
|
||||||
|
border-color: #1c4d3a;
|
||||||
|
background: #162820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--text.is-active {
|
||||||
|
border-color: #1a4d4d;
|
||||||
|
animation: ecom-tree-breathe 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__title {
|
||||||
|
color: #e2eaf4;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__desc {
|
||||||
|
color: #6b7a8a;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Image node (分镜图) ── */
|
||||||
|
.ecom-video-tree-node--image,
|
||||||
|
.ecom-video-tree-node--video {
|
||||||
|
width: 150px;
|
||||||
|
height: 120px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--image img,
|
||||||
|
.ecom-video-tree-node--image video,
|
||||||
|
.ecom-video-tree-node--video img,
|
||||||
|
.ecom-video-tree-node--video video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--image.is-completed,
|
||||||
|
.ecom-video-tree-node--video.is-completed {
|
||||||
|
border-color: #1c4d3a;
|
||||||
|
background: #162820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--image.is-active,
|
||||||
|
.ecom-video-tree-node--video.is-active {
|
||||||
|
border-color: #1a4d4d;
|
||||||
|
animation: ecom-tree-breathe 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node--video.is-failed {
|
||||||
|
border-color: #4d1a1a;
|
||||||
|
background: #2a1b1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__placeholder {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(135deg, #171c22 0%, #12161b 100%);
|
||||||
|
color: #5a6a78;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__placeholder span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__tag {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 8px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #303540;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(18, 20, 26, 0.9);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
color: #c8d4e0;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: #53e5ff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree-node__retry {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 8px;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #4d1a1a;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #241417;
|
||||||
|
color: #ffb1b1;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trunk connector (分支连接线) ── */
|
||||||
|
.ecom-video-tree__trunk {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__trunk-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: #3a4550;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__trunk-line::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, #00ff88, transparent);
|
||||||
|
animation: ecom-tree-trunk-flow 2.4s ease-in-out infinite;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__branches-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__branches-line::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #3a4550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__branch-tap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: #3a4550;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__branch-tap:nth-child(1) { top: 0; }
|
||||||
|
.ecom-video-tree__branch-tap:nth-child(2) { top: 50%; transform: translateY(-50%); }
|
||||||
|
.ecom-video-tree__branch-tap:nth-child(3) { bottom: 0; }
|
||||||
|
|
||||||
|
.ecom-video-tree__branch-tap::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, #00ff88, transparent);
|
||||||
|
animation: ecom-tree-branch-flow 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__branch-tap:nth-child(2)::after { animation-delay: 0.3s; }
|
||||||
|
.ecom-video-tree__branch-tap:nth-child(3)::after { animation-delay: 0.6s; }
|
||||||
|
|
||||||
|
/* ── Arrow between nodes ── */
|
||||||
|
.ecom-video-tree__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 20px;
|
||||||
|
color: #4a5565;
|
||||||
|
transition: color 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__arrow svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__arrow svg path {
|
||||||
|
transition: stroke 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__row:hover .ecom-video-tree__arrow {
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Rows container ── */
|
||||||
|
.ecom-video-tree__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
animation: ecom-tree-row-in 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__row--empty {
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 320ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__row--empty.is-planning {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecom-video-tree__row--empty.is-planning .ecom-video-tree-node {
|
||||||
|
border-color: rgba(var(--accent-rgb, 0, 255, 136), 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes ecom-tree-node-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecom-tree-row-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecom-tree-breathe {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: #1a4d4d;
|
||||||
|
box-shadow: 0 0 0 0 rgba(83, 229, 255, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: #53e5ff;
|
||||||
|
box-shadow: 0 0 16px 2px rgba(83, 229, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecom-tree-trunk-flow {
|
||||||
|
0% { opacity: 0; transform: translateX(-100%); }
|
||||||
|
30% { opacity: 0.6; }
|
||||||
|
70% { opacity: 0.6; }
|
||||||
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ecom-tree-branch-flow {
|
||||||
|
0% { opacity: 0; transform: translateX(-100%); }
|
||||||
|
30% { opacity: 0.5; }
|
||||||
|
70% { opacity: 0.5; }
|
||||||
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3000,6 +3000,346 @@
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
Flowchart Pipeline Layout (参考图风格)
|
||||||
|
流程图式布局:原图 → 分镜文本 → 分镜图 → 分镜视频
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.clone-ai-flow-pipeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
width: min(100%, 1100px);
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Source Node ── */
|
||||||
|
.clone-ai-flow-source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #2c3038;
|
||||||
|
border-radius: var(--radius-sm, 10px);
|
||||||
|
background: #1b1d23;
|
||||||
|
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node:hover {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node--source {
|
||||||
|
width: 160px;
|
||||||
|
height: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node--source img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__placeholder {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
place-items: center;
|
||||||
|
color: #565d6b;
|
||||||
|
font-size: 32px;
|
||||||
|
background: linear-gradient(135deg, #1b1d23 0%, #14161b 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__label {
|
||||||
|
color: #aeb8b1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__tag {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 8px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #303540;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(21, 23, 28, 0.92);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
color: #d8deed;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__tag--accent {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.35);
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text Node ── */
|
||||||
|
.clone-ai-flow-node--text {
|
||||||
|
min-width: 130px;
|
||||||
|
max-width: 160px;
|
||||||
|
padding: 16px 14px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__text-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__text-title {
|
||||||
|
color: #eef2f6;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node__text-desc {
|
||||||
|
color: #758096;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Result Node ── */
|
||||||
|
.clone-ai-flow-node--result,
|
||||||
|
.clone-ai-flow-node--output {
|
||||||
|
width: 180px;
|
||||||
|
height: 140px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node--result img,
|
||||||
|
.clone-ai-flow-node--output img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Video Node ── */
|
||||||
|
.clone-ai-flow-node--video {
|
||||||
|
width: 160px;
|
||||||
|
height: 120px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Connector (分支连接线) ── */
|
||||||
|
.clone-ai-flow-connector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector__trunk {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: #3a3f48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector__branches {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 15%;
|
||||||
|
bottom: 15%;
|
||||||
|
width: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector__branch {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: #3a3f48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector__branch::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: -8px;
|
||||||
|
width: 2px;
|
||||||
|
height: 18px;
|
||||||
|
background: #3a3f48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector__branch:last-child::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector__branches::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #3a3f48;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flow Arrow (节点间箭头) ── */
|
||||||
|
.clone-ai-flow-branch .clone-ai-flow-arrow {
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
clip-path: polygon(0 34%, 55% 34%, 55% 0, 100% 50%, 55% 100%, 55% 66%, 0 66%);
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: clone-ai-arrow-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes clone-ai-arrow-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; transform: translateX(0); }
|
||||||
|
50% { opacity: 1; transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Branches Container ── */
|
||||||
|
.clone-ai-flow-branches {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty State Branches ── */
|
||||||
|
.clone-ai-flow-branches--empty .clone-ai-flow-branch {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branches--empty .clone-ai-flow-branch.is-generating {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branches--empty .clone-ai-flow-branch.is-generating .clone-ai-flow-node {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branches--empty .clone-ai-flow-branch.is-failed {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branches--empty .clone-ai-flow-branch.is-failed .clone-ai-flow-node {
|
||||||
|
border-color: rgba(255, 90, 95, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status Overlay ── */
|
||||||
|
.clone-ai-flow-status {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border: 1px solid #2c3038;
|
||||||
|
border-radius: var(--radius-md, 14px);
|
||||||
|
background: rgba(27, 29, 35, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
width: min(100%, 480px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-status strong {
|
||||||
|
color: #d8deed;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-status span {
|
||||||
|
color: #758096;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-status .anticon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flowchart Responsive ── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.clone-ai-flow-pipeline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-connector {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-source {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node--source {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branches {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-branch {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node--result,
|
||||||
|
.clone-ai-flow-node--output {
|
||||||
|
width: 140px;
|
||||||
|
height: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clone-ai-flow-node--text {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scope to clone tool + video output ── */
|
||||||
|
.product-clone-page[data-tool="clone"] .clone-ai-flow-pipeline {
|
||||||
|
width: min(100%, 1100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
End Flowchart Pipeline Styles
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
.product-clone-page .clone-ai-input-wrapper {
|
.product-clone-page .clone-ai-input-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -8081,6 +8421,7 @@
|
|||||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card,
|
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card,
|
||||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card img {
|
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card img {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
}
|
||||||
.clone-ai-video-outfit-upload {
|
.clone-ai-video-outfit-upload {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -237,7 +237,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-button {
|
.member-button {
|
||||||
color: var(--cyan-strong);
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-button--community {
|
.member-button--community {
|
||||||
|
|||||||
Reference in New Issue
Block a user