merge: 解决合并冲突 - 合并视频流程管道UI、v5样式及新功能模块
@@ -1,8 +1,5 @@
|
||||
# Dev proxy target — the backend API server
|
||||
VITE_DEV_PROXY=http://47.110.225.76:3600
|
||||
|
||||
# Key server URL for auth/profile endpoints
|
||||
VITE_KEY_SERVER_URL=
|
||||
|
||||
# Main API base URL (used when not served from omniai.net.cn)
|
||||
VITE_API_BASE_URL=
|
||||
# Frontend environment variables are intentionally unsupported.
|
||||
#
|
||||
# API traffic must go through same-origin /api.
|
||||
# Public runtime settings must come from application APIs.
|
||||
# Provider keys and OSS credentials must stay on the server.
|
||||
|
||||
@@ -10,6 +10,8 @@ node_modules/
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
coverage/
|
||||
@@ -0,0 +1,39 @@
|
||||
# Project Rules
|
||||
|
||||
## Asset, Key, And Runtime Data Governance
|
||||
|
||||
These rules are mandatory for all frontend, backend, deployment, and agent-generated changes.
|
||||
|
||||
1. Image and media assets must be stored in OSS.
|
||||
- Do not commit product images, demo images, generated images, videos, or other large media assets into `src/assets` or other source folders.
|
||||
- Code may reference media only by OSS URL or by data returned from an API.
|
||||
- Local assets are limited to tiny build-critical files such as icons or placeholders, and require explicit justification.
|
||||
|
||||
2. Frontend code must not contain API keys or secrets.
|
||||
- Do not hard-code provider keys, access keys, tokens, private endpoints, passwords, or bearer tokens in TypeScript, CSS, HTML, Vite config, Nginx snippets, or checked-in docs.
|
||||
- Browser-delivered code must treat every visible value as public.
|
||||
|
||||
3. Provider keys are owned by the server key pool.
|
||||
- AI provider credentials are stored and managed server-side.
|
||||
- The frontend requests work through application APIs; the server leases provider keys from the concurrency/key pool and calls providers on behalf of the client.
|
||||
- Do not add direct browser-to-provider calls that require provider credentials.
|
||||
|
||||
4. Application data must come through APIs.
|
||||
- Do not hard-code product data, pricing, model availability, provider routing, account state, usage state, or operational configuration in the frontend.
|
||||
- Use typed API clients and server-provided payloads for runtime data.
|
||||
- Static constants are allowed only for presentation defaults that are not business-authoritative.
|
||||
|
||||
5. Do not use fixed environment configuration in application code.
|
||||
- Do not bake production hostnames, provider endpoints, keys, or environment-specific behavior into source code.
|
||||
- Environment-specific values belong in server deployment configuration, secret management, or runtime configuration endpoints.
|
||||
- Frontend code must not add fixed `VITE_*` or equivalent environment variables for API hosts, provider hosts, business data, or secrets.
|
||||
- If the browser needs runtime configuration, it must request that data from an application API.
|
||||
|
||||
6. Deployment configuration must follow the same rules.
|
||||
- Nginx and process manager configs must not embed provider API keys or long-lived credentials.
|
||||
- Reverse proxies should route application traffic to the backend, not expose third-party credentials.
|
||||
- Secrets must be rotated immediately if found in source, Git remotes, shell history, Nginx config, process manager config, or logs.
|
||||
|
||||
7. Reviews must reject violations.
|
||||
- Any new local media file, hard-coded key, direct provider credential path, or fixed production config is a blocking issue.
|
||||
- Prefer deleting local assets and replacing them with OSS URLs returned by APIs or server-managed config.
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"governance:check": "node scripts/check-governance.mjs",
|
||||
"style:check": "node scripts/check-style-governance.mjs",
|
||||
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]);
|
||||
const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]);
|
||||
|
||||
const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"];
|
||||
const allowedFiles = new Set([
|
||||
normalizePath("src/data/ossAssets.ts"),
|
||||
normalizePath("src/utils/ossImageOptimize.ts"),
|
||||
]);
|
||||
|
||||
const forbiddenPatterns = [
|
||||
{ label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ },
|
||||
{ label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i },
|
||||
{ label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i },
|
||||
{ label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i },
|
||||
{ label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i },
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function walk(targetPath, visitor) {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of fs.readdirSync(targetPath)) {
|
||||
if (entry === "node_modules" || entry === "dist" || entry === ".git") continue;
|
||||
walk(path.join(targetPath, entry), visitor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
visitor(targetPath, stat);
|
||||
}
|
||||
|
||||
function report(file, message) {
|
||||
failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`);
|
||||
}
|
||||
|
||||
walk(path.join(repoRoot, "src", "assets"), (file) => {
|
||||
if (mediaExtensions.has(path.extname(file).toLowerCase())) {
|
||||
report(file, "media files must live in OSS, not src/assets");
|
||||
}
|
||||
});
|
||||
|
||||
for (const root of scanRoots) {
|
||||
walk(path.join(repoRoot, root), (file) => {
|
||||
const relative = normalizePath(path.relative(repoRoot, file));
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return;
|
||||
if (relative.startsWith("src/assets/")) return;
|
||||
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const isAllowed = allowedFiles.has(relative);
|
||||
for (const rule of forbiddenPatterns) {
|
||||
if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) {
|
||||
continue;
|
||||
}
|
||||
if (rule.pattern.test(content)) {
|
||||
report(file, `forbidden ${rule.label}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Governance check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Governance check passed.");
|
||||
@@ -0,0 +1 @@
|
||||
import "./check-governance.mjs";
|
||||
@@ -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.");
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ToolOutlined,
|
||||
WalletOutlined,
|
||||
} 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 { reportError } from "./utils/errorReporting";
|
||||
import { initNotificationPermission } from "./utils/generationNotifier";
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SERVER_SESSION_REPLACED_EVENT,
|
||||
SERVER_SESSION_EXPIRED_EVENT,
|
||||
checkServerHealth,
|
||||
clearAllUserStorage,
|
||||
getErrorMessage,
|
||||
type ServerSessionReplacedDetail,
|
||||
} from "./api/serverConnection";
|
||||
@@ -143,7 +144,9 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
||||
}
|
||||
|
||||
function readViewFromHash(): WebViewKey {
|
||||
return normalizeViewKey(window.location.hash.replace(/^#\/?/, ""));
|
||||
const raw = window.location.hash.replace(/^#\/?/, "");
|
||||
if (!raw) return "home";
|
||||
return normalizeViewKey(raw);
|
||||
}
|
||||
|
||||
function isWorkspaceView(view: WebViewKey): boolean {
|
||||
@@ -284,6 +287,12 @@ function App() {
|
||||
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
|
||||
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
|
||||
useEffect(() => {
|
||||
const splash = document.getElementById("app-boot-splash");
|
||||
@@ -369,7 +378,7 @@ function App() {
|
||||
}, [setView, setWorkspaceExpanded]);
|
||||
|
||||
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
||||
keyServerClient.clearSession();
|
||||
clearAllUserStorage();
|
||||
clearSessionState();
|
||||
setProjects([]);
|
||||
setProjectsLoaded(true);
|
||||
@@ -1075,20 +1084,7 @@ function App() {
|
||||
return <AssetsPage isAuthenticated={Boolean(session)} onOpenLogin={handleOpenLogin} />;
|
||||
case "ecommerce":
|
||||
case "ecommerceHub":
|
||||
return (
|
||||
<EcommercePage
|
||||
projects={projects}
|
||||
isAuthenticated={Boolean(session)}
|
||||
onStartCreate={handleStartCreate}
|
||||
onOpenProject={handleOpenProject}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onImportWorkflow={handleImportWorkflow}
|
||||
onCreateTask={handleCreateTask}
|
||||
onRequireLogin={handleRequireTaskLogin}
|
||||
initialTemplate={pendingEcommerceTemplate}
|
||||
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
case "digitalHuman":
|
||||
return (
|
||||
<DigitalHumanPage
|
||||
@@ -1231,7 +1227,7 @@ function App() {
|
||||
onMarkNotificationRead={handleMarkNotificationRead}
|
||||
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ErrorBoundary key={activeView}>
|
||||
<Suspense fallback={
|
||||
<div className="page-loading-center">
|
||||
<div className="page-loading-spinner" />
|
||||
@@ -1244,6 +1240,26 @@ function App() {
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
|
||||
{ecommerceEverMounted && (
|
||||
<div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
|
||||
<Suspense fallback={null}>
|
||||
<EcommercePage
|
||||
projects={projects}
|
||||
isAuthenticated={Boolean(session)}
|
||||
onStartCreate={handleStartCreate}
|
||||
onOpenProject={handleOpenProject}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onImportWorkflow={handleImportWorkflow}
|
||||
onCreateTask={handleCreateTask}
|
||||
onRequireLogin={handleRequireTaskLogin}
|
||||
initialTemplate={pendingEcommerceTemplate}
|
||||
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginPromptOpen && pendingAction ? (
|
||||
<div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title">
|
||||
<button
|
||||
|
||||
@@ -67,7 +67,6 @@ let modelCapabilitiesRouteMissing = false;
|
||||
|
||||
export const modelCapabilitiesClient = {
|
||||
async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> {
|
||||
if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities();
|
||||
if (modelCapabilitiesRouteMissing) return createFallbackCapabilities();
|
||||
|
||||
let payload: unknown;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||
import { isRecord, serverRequest } from "./serverConnection";
|
||||
|
||||
export interface WebPublicConfig {
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
companyAddress?: string;
|
||||
icpRecord?: string;
|
||||
}
|
||||
|
||||
function readString(config: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = config[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePublicConfig(raw: unknown): WebPublicConfig {
|
||||
const config = isRecord(raw) && isRecord(raw.config) ? raw.config : raw;
|
||||
if (!isRecord(config)) return {};
|
||||
|
||||
return {
|
||||
contactEmail: readString(config, ["contactEmail", "contact_email", "supportEmail", "support_email"]),
|
||||
contactPhone: readString(config, ["contactPhone", "contact_phone", "supportPhone", "support_phone"]),
|
||||
companyAddress: readString(config, ["companyAddress", "company_address", "address"]),
|
||||
icpRecord: readString(config, ["icpRecord", "icp_record", "filingInfo", "filing_info"]),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedPublicConfig: WebPublicConfig | null = null;
|
||||
let publicConfigRouteMissing = false;
|
||||
|
||||
export const publicConfigClient = {
|
||||
async get(): Promise<WebPublicConfig> {
|
||||
if (cachedPublicConfig) return cachedPublicConfig;
|
||||
if (publicConfigRouteMissing) return {};
|
||||
|
||||
try {
|
||||
const payload = await serverRequest<unknown>("public/config/profile?name=web-public-config");
|
||||
cachedPublicConfig = normalizePublicConfig(payload);
|
||||
return cachedPublicConfig;
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
publicConfigRouteMissing = true;
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { WebUserSession } from "../types";
|
||||
|
||||
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
|
||||
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
|
||||
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
|
||||
@@ -59,34 +58,12 @@ export function compactMessage(value: string): string {
|
||||
}
|
||||
|
||||
export function getServerBaseUrl(): string {
|
||||
const envBaseUrl = String(
|
||||
import.meta.env.VITE_KEY_SERVER_URL ||
|
||||
import.meta.env.VITE_SERVER_BASE_URL ||
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
"",
|
||||
).trim();
|
||||
const shouldUseSameOriginApi =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.protocol === "https:" ||
|
||||
window.location.hostname === "omniai.net.cn" ||
|
||||
window.location.hostname === "www.omniai.net.cn");
|
||||
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
|
||||
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
|
||||
return "";
|
||||
}
|
||||
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string): string {
|
||||
const cleanPath = path.replace(/^\/+/, "");
|
||||
const baseUrl = getServerBaseUrl();
|
||||
if (!baseUrl) return `/api/${cleanPath}`;
|
||||
|
||||
try {
|
||||
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
||||
} catch {
|
||||
return `${baseUrl}/api/${cleanPath}`;
|
||||
}
|
||||
return `/api/${cleanPath}`;
|
||||
}
|
||||
|
||||
export function canUseSessionStorage(): boolean {
|
||||
@@ -167,6 +144,39 @@ export function writeStoredSession(session: WebUserSession | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllUserStorage(): void {
|
||||
writeStoredSession(null);
|
||||
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
const legacyKeys = ["omniai:token", "omniai:session"];
|
||||
for (const key of legacyKeys) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
const prefixKeys = [
|
||||
"omniai-web-profile-ui",
|
||||
"omniai:more-recent-tools",
|
||||
"omniai:generation-queue",
|
||||
"omniai-canvas-saved-assets",
|
||||
];
|
||||
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (key && prefixKeys.some((p) => key.startsWith(p))) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
for (let i = window.sessionStorage.length - 1; i >= 0; i--) {
|
||||
const key = window.sessionStorage.key(i);
|
||||
if (key && prefixKeys.some((p) => key.startsWith(p))) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
return readStoredSession()?.token ?? null;
|
||||
}
|
||||
@@ -226,6 +236,15 @@ let lastSessionReplacedEventAt = 0;
|
||||
|
||||
let lastSessionExpiredEventAt = 0;
|
||||
|
||||
function isNonAuthErrorCode(code: string | undefined): boolean {
|
||||
if (!code) return false;
|
||||
return [
|
||||
"ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED",
|
||||
"INSUFFICIENT_BALANCE",
|
||||
"INSUFFICIENT_ENTERPRISE_BALANCE",
|
||||
].includes(code);
|
||||
}
|
||||
|
||||
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
|
||||
if (status !== 401 && status !== 403) return;
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -238,6 +257,9 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
|
||||
if (!readStoredSession()) return;
|
||||
// Deliberate early-exit for unauthenticated users — not a real auth failure.
|
||||
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
|
||||
// Non-auth 403 errors (enterprise model access, insufficient balance) must
|
||||
// not trigger session expiry.
|
||||
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSessionExpiredEventAt < 1500) return;
|
||||
@@ -341,6 +363,7 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
|
||||
headers,
|
||||
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller ? controller.signal : options?.signal,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const payload = await readJsonResponse<unknown>(response, "Request failed");
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 5.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 5.5 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 7.6 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 5.5 MiB |
@@ -6,16 +6,15 @@ import {
|
||||
InfoCircleOutlined,
|
||||
LoginOutlined,
|
||||
LogoutOutlined,
|
||||
PhoneOutlined,
|
||||
SafetyOutlined,
|
||||
EnvironmentOutlined,
|
||||
PlusCircleOutlined,
|
||||
UserOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
|
||||
import type { ServerConnectionHealth } from "../api/serverConnection";
|
||||
import { ossAssets } from "../data/ossAssets";
|
||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
@@ -40,8 +39,7 @@ interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
|
||||
const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1";
|
||||
const BRAND_LOGO_URL = ossAssets.brand.logo;
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
@@ -71,6 +69,7 @@ function AppShell({
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoRef = useRef<HTMLDivElement>(null);
|
||||
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
||||
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
||||
const prevActiveViewRef = useRef<WebViewKey>(activeView);
|
||||
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
|
||||
const isAuthView = activeView === "login";
|
||||
@@ -90,7 +89,7 @@ function AppShell({
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
const showPageScrollActions = false;
|
||||
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => {
|
||||
@@ -136,6 +135,22 @@ function AppShell({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
publicConfigClient
|
||||
.get()
|
||||
.then((config) => {
|
||||
if (!cancelled) setPublicConfig(config);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicConfig({});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileOpen) return;
|
||||
|
||||
@@ -220,7 +235,6 @@ function AppShell({
|
||||
? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents)
|
||||
: usage.balanceCents;
|
||||
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
||||
const isPreviewSession = session?.source === "mock-fallback";
|
||||
const showCommunityReview = canReviewCommunity(session);
|
||||
const showCommunityCaseAdd = canManageCommunityCases(session);
|
||||
|
||||
@@ -339,11 +353,11 @@ function AppShell({
|
||||
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
|
||||
<dl>
|
||||
<dt>备案信息</dt>
|
||||
<dd>苏ICP备2026021747号-1</dd>
|
||||
<dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
|
||||
<dt>公司地址</dt>
|
||||
<dd>江苏省南京市江北新区扬子江数字视听产业园9栋A楼501</dd>
|
||||
<dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
|
||||
<dt>联系电话</dt>
|
||||
<dd>15155073618</dd>
|
||||
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
|
||||
</dl>
|
||||
<div className="info-popover__links">
|
||||
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||
@@ -407,7 +421,7 @@ function AppShell({
|
||||
<dd>{usage.videoUsed}</dd>
|
||||
</dl>
|
||||
<div className="profile-popover__footer">
|
||||
<span>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span>
|
||||
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
|
||||
<button type="button" onClick={onLogout}>
|
||||
<LogoutOutlined />
|
||||
退出
|
||||
@@ -473,7 +487,7 @@ function AppShell({
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
<CookieConsentBanner />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
function CookieConsentBanner() {
|
||||
return null; // TODO: implement cookie consent UI
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default CookieConsentBanner;
|
||||
|
||||
@@ -80,6 +80,8 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
|
||||
|
||||
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
|
||||
|
||||
if (!displayedChildren) return null;
|
||||
|
||||
return (
|
||||
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : "page-transition-wrap"}>
|
||||
{displayedChildren}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const OSS_PUBLIC_BASE_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com";
|
||||
|
||||
function oss(path: string): string {
|
||||
return `${OSS_PUBLIC_BASE_URL}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
function muban(path: string): string {
|
||||
return oss(`muban/${path.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
function toolbox(path: string): string {
|
||||
return oss(`static/toolbox/${path.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
export const ossAssets = {
|
||||
brand: {
|
||||
logo: oss("logo.png"),
|
||||
},
|
||||
auth: {
|
||||
showcaseVideo: oss("test5.mp4"),
|
||||
},
|
||||
home: {
|
||||
backgroundVideo: muban("hero-bg.mp4"),
|
||||
heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
|
||||
features: {
|
||||
ecommerce: muban("feature-ecommerce.jpg"),
|
||||
script: muban("feature-script.jpg"),
|
||||
token: muban("feature-token.jpg"),
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
imageBefore: toolbox("%E7%89%9B%E4%BB%94.webp"),
|
||||
imageAfter: toolbox("%E8%A5%BF%E8%A3%85.webp"),
|
||||
watermarkBefore: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%89%8D.webp"),
|
||||
watermarkAfter: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%90%8E.webp"),
|
||||
},
|
||||
community: {
|
||||
cardImages: [
|
||||
muban("dianshang1.png"),
|
||||
muban("dianshang2.png"),
|
||||
muban("dianshang3.png"),
|
||||
muban("wechat-7.png"),
|
||||
muban("wechat-8.png"),
|
||||
muban("wechat-9.png"),
|
||||
],
|
||||
carouselVideos: [oss("test3.mp4"), oss("test4.mp4"), oss("test6.mp4")],
|
||||
},
|
||||
workflows: {
|
||||
caseImages: [
|
||||
muban("community/workflow-rain-night.jpg"),
|
||||
muban("community/workflow-character-look.jpg"),
|
||||
muban("community/workflow-skyline.jpg"),
|
||||
muban("community/workflow-lab.jpg"),
|
||||
],
|
||||
},
|
||||
ecommerce: {
|
||||
generated: muban("ecommerce-carousel-generated.png"),
|
||||
slides: {
|
||||
slide4: muban("slide-4.png"),
|
||||
slide5: muban("slide-5.png"),
|
||||
},
|
||||
heroSlides: [
|
||||
muban("ecommerce-hero-carousel/slide-1.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-2.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-3.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-4.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-5.webp"),
|
||||
],
|
||||
templateSlides: [
|
||||
muban("more-template-carousel/slide-1.jpg"),
|
||||
muban("more-template-carousel/slide-2.jpg"),
|
||||
muban("more-template-carousel/slide-3.jpg"),
|
||||
muban("more-template-carousel/slide-4.png"),
|
||||
muban("more-template-carousel/slide-5.gif"),
|
||||
],
|
||||
templateCases: [
|
||||
muban("ecommerce/templates/case-1.png"),
|
||||
muban("ecommerce/templates/case-2.png"),
|
||||
muban("ecommerce/templates/case-3.png"),
|
||||
muban("ecommerce/templates/case-4.png"),
|
||||
muban("ecommerce/templates/case-5.png"),
|
||||
muban("ecommerce/templates/case-6.png"),
|
||||
],
|
||||
productSet: {
|
||||
main: muban("ecommerce/product-set/main.webp"),
|
||||
scene: muban("ecommerce/product-set/scene.webp"),
|
||||
model: muban("ecommerce/product-set/model.webp"),
|
||||
detail: muban("ecommerce/product-set/detail.webp"),
|
||||
selling: muban("ecommerce/product-set/selling.webp"),
|
||||
hosting: muban("ecommerce/product-set/hosting.webp"),
|
||||
},
|
||||
tryOn: {
|
||||
dressA: muban("ecommerce/try-on/dress-a.webp"),
|
||||
dressB: muban("ecommerce/try-on/dress-b.webp"),
|
||||
modelWoman: muban("ecommerce/try-on/model-woman.webp"),
|
||||
modelMan: muban("ecommerce/try-on/model-man.webp"),
|
||||
modelAsian: muban("ecommerce/try-on/model-asian.webp"),
|
||||
tryA: muban("ecommerce/try-on/result-a.webp"),
|
||||
tryB: muban("ecommerce/try-on/result-b.webp"),
|
||||
jacket: muban("ecommerce/try-on/jacket.webp"),
|
||||
jacketResultA: muban("ecommerce/try-on/jacket-result-a.webp"),
|
||||
jacketResultB: muban("ecommerce/try-on/jacket-result-b.webp"),
|
||||
hat: muban("ecommerce/try-on/hat.webp"),
|
||||
hatResultA: muban("ecommerce/try-on/hat-result-a.webp"),
|
||||
hatResultB: muban("ecommerce/try-on/hat-result-b.webp"),
|
||||
},
|
||||
detail: {
|
||||
productA: muban("ecommerce/detail/product-a.webp"),
|
||||
productB: muban("ecommerce/detail/product-b.webp"),
|
||||
productC: muban("ecommerce/detail/product-c.webp"),
|
||||
longPage: muban("ecommerce/detail/long-page.webp"),
|
||||
gridA: muban("ecommerce/detail/grid-a.webp"),
|
||||
gridB: muban("ecommerce/detail/grid-b.webp"),
|
||||
gridC: muban("ecommerce/detail/grid-c.webp"),
|
||||
gridD: muban("ecommerce/detail/grid-d.webp"),
|
||||
gridE: muban("ecommerce/detail/grid-e.webp"),
|
||||
gridF: muban("ecommerce/detail/grid-f.webp"),
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ProductSetOssAssets = typeof ossAssets.ecommerce.productSet;
|
||||
export type TryOnOssAssets = typeof ossAssets.ecommerce.tryOn;
|
||||
export type DetailOssAssets = typeof ossAssets.ecommerce.detail;
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { WebCanvasWorkflow, WebCommunityCase } from "../types";
|
||||
import { ossAssets } from "./ossAssets";
|
||||
|
||||
const [rainNightImage, characterLookImage, skylineImage, labImage] = ossAssets.workflows.caseImages;
|
||||
|
||||
function createNodes(
|
||||
title: string,
|
||||
@@ -69,7 +72,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "Dave",
|
||||
tag: "视频案例",
|
||||
summary: "从街口推到人物面部,强调雨夜反光与情绪收束。",
|
||||
imageUrl: "https://picsum.photos/id/1011/900/540",
|
||||
imageUrl: rainNightImage,
|
||||
workflow: {
|
||||
id: "workflow-rain-night",
|
||||
version: 1,
|
||||
@@ -83,7 +86,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "6s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", "https://picsum.photos/id/1011/960/540"),
|
||||
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", rainNightImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -93,7 +96,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "SuperXe",
|
||||
tag: "角色案例",
|
||||
summary: "把单张角色图扩展成可连续出片的角色工作流。",
|
||||
imageUrl: "https://picsum.photos/id/1027/900/540",
|
||||
imageUrl: characterLookImage,
|
||||
workflow: {
|
||||
id: "workflow-character-look",
|
||||
version: 1,
|
||||
@@ -107,7 +110,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "5s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", "https://picsum.photos/id/1027/960/540"),
|
||||
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", characterLookImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -117,7 +120,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "OmniAI",
|
||||
tag: "风景案例",
|
||||
summary: "用广角风景做镜头进入,适合转场和开场片头。",
|
||||
imageUrl: "https://picsum.photos/id/1050/900/540",
|
||||
imageUrl: skylineImage,
|
||||
workflow: {
|
||||
id: "workflow-skyline",
|
||||
version: 1,
|
||||
@@ -131,7 +134,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "8s",
|
||||
resolution: "1080p",
|
||||
},
|
||||
nodes: createNodes("风景开场,镜头缓慢推进到天际线", "https://picsum.photos/id/1050/960/540"),
|
||||
nodes: createNodes("风景开场,镜头缓慢推进到天际线", skylineImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -141,7 +144,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "Studio",
|
||||
tag: "实验案例",
|
||||
summary: "更适合拆解推拉摇移和节奏控制的实验模板。",
|
||||
imageUrl: "https://picsum.photos/id/1056/900/540",
|
||||
imageUrl: labImage,
|
||||
workflow: {
|
||||
id: "workflow-lab",
|
||||
version: 1,
|
||||
@@ -155,7 +158,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "6s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", "https://picsum.photos/id/1056/960/540"),
|
||||
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", labImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2646,6 +2646,22 @@ function CanvasPage({
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: connectionDropMenu
|
||||
? (() => {
|
||||
const source = getNodePortPoint(connectionDropMenu.sourcePort);
|
||||
const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
return source
|
||||
? {
|
||||
id: "pending-link-preview",
|
||||
sourceX: source.x,
|
||||
sourceY: source.y,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
sourceSide: connectionDropMenu.sourcePort.side,
|
||||
targetSide: null,
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
|
||||
@@ -2816,6 +2832,8 @@ function CanvasPage({
|
||||
originTop: event.clientY,
|
||||
sourcePort: connectorDrag.port,
|
||||
});
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
}
|
||||
} else {
|
||||
clearPendingConnector();
|
||||
@@ -2840,7 +2858,7 @@ function CanvasPage({
|
||||
}, [selectedNode]);
|
||||
|
||||
const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
|
||||
if (!pendingLinkPort) return;
|
||||
if (!pendingLinkPort || connectionDropMenu) return;
|
||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
||||
};
|
||||
|
||||
@@ -5534,11 +5552,6 @@ function CanvasPage({
|
||||
role="menu"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => {
|
||||
if (pendingLinkPort) {
|
||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
|
||||
<button
|
||||
|
||||
@@ -16,10 +16,10 @@ import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import OptimizedImage from "../../components/OptimizedImage";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import type { WebCanvasWorkflow, WebProjectSummary } from "../../types";
|
||||
import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils";
|
||||
import { ossThumb } from "../../utils/ossImageOptimize";
|
||||
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
|
||||
|
||||
interface CommunityPageProps {
|
||||
projects: WebProjectSummary[];
|
||||
@@ -31,23 +31,12 @@ interface CommunityPageProps {
|
||||
onRequireLogin?: (action: string) => boolean | void;
|
||||
}
|
||||
|
||||
const communityCardImages = [
|
||||
`${OSS_MUBAN}/dianshang1.png`,
|
||||
`${OSS_MUBAN}/dianshang2.png`,
|
||||
`${OSS_MUBAN}/dianshang3.png`,
|
||||
`${OSS_MUBAN}/wechat-7.png`,
|
||||
`${OSS_MUBAN}/wechat-8.png`,
|
||||
`${OSS_MUBAN}/wechat-9.png`,
|
||||
];
|
||||
const communityCardImages = ossAssets.community.cardImages;
|
||||
|
||||
const SLIDE_INTERVAL = 3000;
|
||||
const CAROUSEL_VISIBLE_COUNT = 3;
|
||||
const MANUAL_PAUSE_DURATION = 2000;
|
||||
const COMMUNITY_CAROUSEL_VIDEOS = [
|
||||
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4",
|
||||
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4",
|
||||
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4",
|
||||
];
|
||||
const COMMUNITY_CAROUSEL_VIDEOS = ossAssets.community.carouselVideos;
|
||||
|
||||
function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow {
|
||||
const workflow = getWorkflowFromCase(item);
|
||||
|
||||
@@ -1,14 +1,98 @@
|
||||
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
|
||||
|
||||
type ComplianceKind = "agreement" | "privacy";
|
||||
|
||||
interface CompliancePageProps {
|
||||
kind: "agreement" | "privacy";
|
||||
kind: ComplianceKind;
|
||||
}
|
||||
|
||||
function CompliancePage({ kind }: CompliancePageProps) {
|
||||
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 (
|
||||
<div style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
|
||||
<h1>{kind === "agreement" ? "用户协议" : "隐私政策"}</h1>
|
||||
<p>内容加载中...</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompliancePage;
|
||||
|
||||
@@ -114,12 +114,12 @@ function DigitalHumanPage({
|
||||
keepaliveRestoredRef.current = true;
|
||||
const saved = loadToolTaskState("digital-human");
|
||||
if (!saved || saved.resultUrl) return;
|
||||
setIsProcessing(true);
|
||||
setIsCreating(true);
|
||||
cancelRef.current = false;
|
||||
pollRunRef.current += 1;
|
||||
setActiveTaskId(saved.taskId);
|
||||
void waitForTaskResult(saved.taskId).catch(() => {});
|
||||
setStatus("正在恢复数字人任务...");
|
||||
setNotice("正在恢复数字人任务...");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,14 +13,11 @@ import {
|
||||
SkinOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import { EcommerceProgressBar } from "./EcommerceProgressBar";
|
||||
|
||||
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
|
||||
const ecommerceGenerated = `${OSS_MUBAN}/ecommerce-carousel-generated.png`;
|
||||
const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`;
|
||||
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
|
||||
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
|
||||
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
|
||||
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
|
||||
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
|
||||
import EcommerceSetPanel from "./panels/EcommerceSetPanel";
|
||||
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
|
||||
@@ -71,6 +68,7 @@ interface CloneResult {
|
||||
id: string;
|
||||
src: string;
|
||||
label: string;
|
||||
type?: "image" | "video";
|
||||
}
|
||||
|
||||
interface CloneSavedSetting {
|
||||
@@ -572,7 +570,7 @@ const maxCloneSetTotal = 16;
|
||||
const maxCloneProductImages = 7;
|
||||
const maxCloneReferenceImages = 20;
|
||||
const cloneVideoDurationMin = 5;
|
||||
const cloneVideoDurationMax = 15;
|
||||
const cloneVideoDurationMax = 45;
|
||||
const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
|
||||
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
|
||||
{ key: "standard", label: "标准", desc: "快速出片" },
|
||||
@@ -596,15 +594,12 @@ const tryOnModelOptions = {
|
||||
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
|
||||
body: ["标准", "高挑", "微胖", "运动"],
|
||||
};
|
||||
const sampleResults = [ecommerceSlide4, ecommerceGenerated, ecommerceSlide5];
|
||||
const productSetAssets = {
|
||||
main: "https://xiuxiu-pro.meitudata.com/poster/6e3eebacad8d5e47e1896ee7d54827bc.png?imageView2/2/w/800/format/webp/q/80/ignore-error/1",
|
||||
scene: "https://xiuxiu-pro.meitudata.com/poster/21225fc86b28d9e4d85636483c67408e.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
|
||||
model: "https://xiuxiu-pro.meitudata.com/poster/4b8e6d1bd0996be52822dd1fac73cffd.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
|
||||
detail: "https://xiuxiu-pro.meitudata.com/poster/29dd195a450ee5a7f7451ded6680e969.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
|
||||
selling: "https://xiuxiu-pro.meitudata.com/poster/66bdef541b67588e8db2a03b39dc815b.jpg?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
|
||||
hosting: "https://xiuxiu-pro-new.meitudata.com/poster/50c17a98c77fac4d0523c8cbdf0d33ca.jpg?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
};
|
||||
const sampleResults = [
|
||||
ossAssets.ecommerce.slides.slide4,
|
||||
ossAssets.ecommerce.generated,
|
||||
ossAssets.ecommerce.slides.slide5,
|
||||
];
|
||||
const productSetAssets = ossAssets.ecommerce.productSet;
|
||||
const productSetPreviewCards = [
|
||||
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
|
||||
{ id: "scene", label: "02 场景展示", src: productSetAssets.scene },
|
||||
@@ -612,21 +607,7 @@ const productSetPreviewCards = [
|
||||
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail },
|
||||
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
|
||||
];
|
||||
const tryOnAssets = {
|
||||
dressA: "https://xiuxiu-pro-new.meitudata.com/poster/133ca2d6c13bac6cfaa11fa29a155551.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
dressB: "https://xiuxiu-pro-new.meitudata.com/poster/a661006820e888d9df13023075096e94.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
modelWoman: "https://xiuxiu-pro-new.meitudata.com/poster/f806c6afaf6f38f634c156c5b6058201.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
modelMan: "https://xiuxiu-pro-new.meitudata.com/poster/8c26503c67dc695e25e420e48caf4cde.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
modelAsian: "https://xiuxiu-pro-new.meitudata.com/poster/0f2a7c92707312ec74647d66f15a6ef9.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
tryA: "https://xiuxiu-pro-new.meitudata.com/poster/7f77e0866f05ff723959e1f48830713c.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
|
||||
tryB: "https://xiuxiu-pro-new.meitudata.com/poster/0b951004eabcdd7cae595dfdb4c7f8c3.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
|
||||
jacket: "https://xiuxiu-pro-new.meitudata.com/poster/fdbf10b4c92af5b1986444cdd9affaa5.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
jacketResultA: "https://xiuxiu-pro-new.meitudata.com/poster/b1152bb292323b87696dd2f6e518e818.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
|
||||
jacketResultB: "https://xiuxiu-pro-new.meitudata.com/poster/1c1e757702108fef92d85be0c2802c01.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
|
||||
hat: "https://xiuxiu-pro-new.meitudata.com/poster/278af735b076ab812888802d3e3db0b8.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
|
||||
hatResultA: "https://xiuxiu-pro-new.meitudata.com/poster/a3ba241b7aa6060869b096d3f10e5db4.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
|
||||
hatResultB: "https://xiuxiu-pro-new.meitudata.com/poster/01ed1ae80a187c70c682bb6d0ec6fa68.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
|
||||
};
|
||||
const tryOnAssets = ossAssets.ecommerce.tryOn;
|
||||
|
||||
const tryOnCards = [
|
||||
{
|
||||
@@ -671,18 +652,7 @@ const detailModules = [
|
||||
const defaultDetailModuleIds: string[] = [];
|
||||
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
|
||||
const cloneDetailModules = detailModules;
|
||||
const detailAssets = {
|
||||
productA: "https://xiuxiu-pro.meitudata.com/poster/182676711565ee98e20cf92d766d1643.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
productB: "https://xiuxiu-pro.meitudata.com/poster/ba6312cbc3a32ceb8966f9ea20b9ee9c.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
productC: "https://xiuxiu-pro.meitudata.com/poster/7ee5753a3141fa12cda155126c8225d3.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
longPage: "https://xiuxiu-pro.meitudata.com/poster/19ef313484fc87c9bdd3cd52ce2a5947.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
gridA: "https://xiuxiu-pro.meitudata.com/poster/e74e8d920ac0f87020f90457d42a7153.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
gridB: "https://xiuxiu-pro.meitudata.com/poster/1652064f17c5c2b32ce287244b505c15.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
gridC: "https://xiuxiu-pro.meitudata.com/poster/dd8abace327edf61d8a8e2d7db42cfbe.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
gridD: "https://xiuxiu-pro.meitudata.com/poster/7dc397f1cb76a35f7f0ed3c3ce78ba81.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
gridE: "https://xiuxiu-pro.meitudata.com/poster/1199bd8b968a5162752e1ee2b093d315.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
gridF: "https://xiuxiu-pro.meitudata.com/poster/7a8cdb3693418df9915741960f8f5aa8.png?imageView2/2/format/webp/q/80/ignore-error/1",
|
||||
};
|
||||
const detailAssets = ossAssets.ecommerce.detail;
|
||||
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
|
||||
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
|
||||
|
||||
@@ -786,9 +756,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [showHostingModal, setShowHostingModal] = useState(false);
|
||||
const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
|
||||
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
|
||||
const [isPageDragging, setIsPageDragging] = useState(false);
|
||||
const pageDragCounterRef = useRef(0);
|
||||
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
|
||||
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
|
||||
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
|
||||
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
|
||||
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
|
||||
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
|
||||
@@ -865,13 +835,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
|
||||
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
|
||||
const canGenerate = (cloneOutput === "video-outfit"
|
||||
? videoOutfitVideoFile && videoOutfitRefFile
|
||||
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
|
||||
: productImages.length > 0) && status !== "generating";
|
||||
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
|
||||
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
|
||||
const cloneVideoDurationProgress =
|
||||
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
|
||||
const cloneVideoDurationStyle = {
|
||||
const cloneVideoDurationStyle: CSSProperties = {
|
||||
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
|
||||
} as CSSProperties;
|
||||
|
||||
@@ -1297,63 +1267,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
};
|
||||
}, [openCloneModelSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
pageDragCounterRef.current += 1;
|
||||
if (pageDragCounterRef.current === 1) {
|
||||
setIsPageDragging(true);
|
||||
}
|
||||
};
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
pageDragCounterRef.current -= 1;
|
||||
if (pageDragCounterRef.current <= 0) {
|
||||
pageDragCounterRef.current = 0;
|
||||
setIsPageDragging(false);
|
||||
}
|
||||
};
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
pageDragCounterRef.current = 0;
|
||||
setIsPageDragging(false);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!files.length) return;
|
||||
if (activeTool === "clone") {
|
||||
addProductImages(files);
|
||||
} else if (activeTool === "set") {
|
||||
addSetImages(files);
|
||||
} else if (activeTool === "detail") {
|
||||
setDetailProductImages((current) => {
|
||||
const remaining = 3 - current.length;
|
||||
if (remaining <= 0) return current;
|
||||
const next = createObjectImageItems(files, remaining, "detail");
|
||||
return next.length ? [...current, ...next].slice(0, 3) : current;
|
||||
});
|
||||
} else if (activeTool === "wear") {
|
||||
setGarmentImages((current) => {
|
||||
const remaining = 5 - current.length;
|
||||
if (remaining <= 0) return current;
|
||||
const next = createObjectImageItems(files, remaining, "garment");
|
||||
return next.length ? [...current, ...next].slice(0, 5) : current;
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("dragenter", handleDragEnter);
|
||||
window.addEventListener("dragleave", handleDragLeave);
|
||||
window.addEventListener("dragover", handleDragOver);
|
||||
window.addEventListener("drop", handleDrop);
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", handleDragEnter);
|
||||
window.removeEventListener("dragleave", handleDragLeave);
|
||||
window.removeEventListener("dragover", handleDragOver);
|
||||
window.removeEventListener("drop", handleDrop);
|
||||
};
|
||||
}, [activeTool, addProductImages, addSetImages]);
|
||||
|
||||
const handleGarmentUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) return;
|
||||
@@ -1472,7 +1385,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
pRatio: string,
|
||||
pLanguage: string,
|
||||
pMarket: string,
|
||||
setStatusFn: (status: "generating" | "done" | "idle") => void,
|
||||
setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||
setResultFn: (urls: string[]) => void,
|
||||
): Promise<void> => {
|
||||
setStatusFn("generating");
|
||||
@@ -1543,13 +1456,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
pMarket: string,
|
||||
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
|
||||
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||
resultFn?: (results: CloneImageItem[]) => void,
|
||||
resultFn?: (results: CloneResult[]) => void,
|
||||
): Promise<void> => {
|
||||
setStatusFn("generating");
|
||||
statusFn?.("generating");
|
||||
try {
|
||||
const referenceUrls = await uploadCloneImages(images);
|
||||
if (!referenceUrls.length) {
|
||||
setStatusFn("idle");
|
||||
statusFn?.("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1573,22 +1486,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
|
||||
if (resultUrl) {
|
||||
setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||
setStatusFn("done");
|
||||
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||
statusFn?.("done");
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
|
||||
} else {
|
||||
setStatusFn("idle");
|
||||
statusFn?.("idle");
|
||||
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ServerRequestError && err.status === 402) {
|
||||
setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
|
||||
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
|
||||
toast.error("余额不足,请充值后继续");
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : "生成失败";
|
||||
toast.error(msg);
|
||||
}
|
||||
setStatusFn("failed");
|
||||
statusFn?.("failed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1622,10 +1535,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
|
||||
const { waitForTask } = await import("../../api/taskSubscription");
|
||||
abortRef.current = { current: false };
|
||||
const resultUrl = await waitForTask(taskId, { abortRef: abortRef.current });
|
||||
imageAbortRef.current = { current: false };
|
||||
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
|
||||
if (resultUrl) {
|
||||
setResults([{ id: crypto.randomUUID(), name: "换装视频", src: resultUrl, type: "video", size: 0 }]);
|
||||
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
|
||||
}
|
||||
setStatus("done");
|
||||
} catch (err) {
|
||||
@@ -1661,7 +1574,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
void generateEcommerceImage(
|
||||
cloneOutput, productImages, requirement,
|
||||
platform, ratio, language, market,
|
||||
(s) => setStatus(s as ProductCloneStatus), setResults,
|
||||
undefined,
|
||||
(s: string) => setStatus(s as ProductCloneStatus), setResults,
|
||||
);
|
||||
lastFailedActionRef.current = () => handleGenerate();
|
||||
}
|
||||
@@ -1740,7 +1654,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
void generateEcommerceImage(
|
||||
"detail", detailProductImages, detailRequirement,
|
||||
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
|
||||
(s) => setDetailStatus(s as DetailStatus),
|
||||
undefined,
|
||||
(s: string) => setDetailStatus(s as DetailStatus),
|
||||
(res) => setDetailResultUrl(res[0]?.src ?? null),
|
||||
);
|
||||
};
|
||||
@@ -1964,6 +1879,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
handleGenerate={handleGenerate}
|
||||
formatRatioDisplayValue={formatRatioDisplayValue}
|
||||
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
|
||||
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2068,11 +1984,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
onClick={() => openProductSetPreview(setPreviewCards[0] ?? productSetPreviewCards[0])}
|
||||
>
|
||||
<img src={setImages[0]?.src ?? (setPreviewCards[0]?.src ?? productSetPreviewCards[0].src)} alt="商品原图" />
|
||||
{setImages[0]?.src ? (
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={setImages[0].src} alt="" />
|
||||
</span>
|
||||
) : null}
|
||||
<span>原图素材</span>
|
||||
</button>
|
||||
<div className="product-set-flow-arrow" aria-hidden="true" />
|
||||
@@ -2126,24 +2037,131 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
<span>
|
||||
上传商品图,AI 即刻生成 <b>符合多电商平台规范</b> 的高转化率商品素材。
|
||||
</span>
|
||||
<div className="clone-ai-preview-summary" aria-label="当前生成配置">
|
||||
<span>{selectedCloneOutput.label}</span>
|
||||
<span>{platform}</span>
|
||||
<span>{market}</span>
|
||||
<span>{language}</span>
|
||||
<span>{formatRatioDisplayValue(ratio)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{cloneOutput === "video" ? (
|
||||
<>
|
||||
<section className="clone-ai-flow-pipeline" aria-label="生成流程">
|
||||
{/* Source Node — 原图素材 */}
|
||||
<div className="clone-ai-flow-source">
|
||||
<div className="clone-ai-flow-node clone-ai-flow-node--source">
|
||||
{productImages[0]?.src ? (
|
||||
<img src={productImages[0].src} alt="商品原图" />
|
||||
) : (
|
||||
<div className="clone-ai-flow-node__placeholder">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="clone-ai-flow-node__label">附件原图</span>
|
||||
</div>
|
||||
|
||||
{/* Connector — 分支连接线 */}
|
||||
<div className="clone-ai-flow-connector" aria-hidden="true">
|
||||
<div className="clone-ai-flow-connector__trunk" />
|
||||
<div className="clone-ai-flow-connector__branches">
|
||||
<div className="clone-ai-flow-connector__branch" />
|
||||
<div className="clone-ai-flow-connector__branch" />
|
||||
<div className="clone-ai-flow-connector__branch" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branches — 生成路径分支 */}
|
||||
{status === "done" ? (
|
||||
<div className="clone-ai-flow-branches">
|
||||
{results[0]?.src ? (
|
||||
<div className="clone-ai-flow-branch">
|
||||
<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">{selectedCloneOutput.label}</span>
|
||||
<span className="clone-ai-flow-node__text-desc">{requirement || "AI智能生成"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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="上传商品原图" />
|
||||
{productImages[0]?.src ? (
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={productImages[0].src} alt="" />
|
||||
</span>
|
||||
) : null}
|
||||
<span>原图素材</span>
|
||||
</button>
|
||||
<div className="clone-ai-flow-arrow" aria-hidden="true" />
|
||||
@@ -2182,6 +2200,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="clone-ai-bottom-input" aria-label="信息详情">
|
||||
<div className="clone-ai-input-wrapper">
|
||||
@@ -2227,11 +2247,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
{(detailProductImages.length ? detailProductImages.map((item) => item.src) : detailProductSamples).map((src, index) => (
|
||||
<figure key={`${src}-${index}`}>
|
||||
<img src={src} alt={`商品原图 ${index + 1}`} />
|
||||
{detailProductImages.length ? (
|
||||
<span className="uploaded-image-zoom" aria-hidden="true">
|
||||
<img src={src} alt="" />
|
||||
</span>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
<span>上传产品图</span>
|
||||
@@ -2315,15 +2330,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}${isPageDragging ? " is-page-dragging" : ""}`}
|
||||
className={`product-clone-page page-motion${isCloneTool && isCloneSettingsCollapsed ? " is-settings-collapsed" : ""}`}
|
||||
data-tool={activeTool}
|
||||
aria-label={pageLabel}
|
||||
>
|
||||
{isPageDragging ? (
|
||||
<div className="ecommerce-drag-overlay" aria-hidden="true">
|
||||
<span>松开上传文件</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="product-clone-shell">
|
||||
<aside className="product-clone-rail" aria-label="商品工具">
|
||||
{sideTools.map((tool) => (
|
||||
@@ -2369,6 +2379,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
durationSeconds={cloneVideoDuration}
|
||||
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
|
||||
onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
|
||||
onOpenHistory={() => setVideoHistoryVisible(true)}
|
||||
triggerPlan={videoPlanTrigger}
|
||||
/>
|
||||
</main>
|
||||
) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? (
|
||||
@@ -2437,6 +2449,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<EcommerceVideoHistoryPanel
|
||||
visible={videoHistoryVisible}
|
||||
onClose={() => setVideoHistoryVisible(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FolderAddOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
|
||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
||||
import {
|
||||
PLAN_STEP_LABELS,
|
||||
PLAN_STEPS_DISPLAY,
|
||||
@@ -39,6 +40,8 @@ interface EcommerceVideoWorkspaceProps {
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
onRequestLogin?: () => void;
|
||||
onOpenHistory?: () => void;
|
||||
triggerPlan?: number;
|
||||
}
|
||||
|
||||
const ALL_STEPS: PlanStep[] = [
|
||||
@@ -100,6 +103,8 @@ export default function EcommerceVideoWorkspace({
|
||||
durationSeconds,
|
||||
resolution,
|
||||
onRequestLogin,
|
||||
onOpenHistory,
|
||||
triggerPlan,
|
||||
}: EcommerceVideoWorkspaceProps) {
|
||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||
@@ -111,6 +116,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
@@ -145,26 +151,45 @@ export default function EcommerceVideoWorkspace({
|
||||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||
|
||||
// ── Auto-advance: skip manual "next step" clicks ─────────
|
||||
const autoAdvanceTriggeredRef = useRef(false);
|
||||
// ── Auto-advance: automatically run the full pipeline ─────────
|
||||
useEffect(() => {
|
||||
if (autoAdvanceTriggeredRef.current) return;
|
||||
const delay = 600;
|
||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "idle" || stage === "cancelled") {
|
||||
autoAdvanceTriggeredRef.current = false;
|
||||
}
|
||||
}, [stage, scenes, planResult]);
|
||||
|
||||
// ── External trigger: start plan from parent ────────────────
|
||||
const triggerPlanPrevRef = useRef(triggerPlan);
|
||||
useEffect(() => {
|
||||
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
|
||||
triggerPlanPrevRef.current = triggerPlan;
|
||||
void handlePlan();
|
||||
}
|
||||
}, [triggerPlan]);
|
||||
|
||||
// ── Auto-save: persist completed results to server ──────────
|
||||
const historySavedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (stage !== "completed") { historySavedRef.current = false; return; }
|
||||
if (historySavedRef.current) return;
|
||||
if (!planResult || !scenes.length) return;
|
||||
historySavedRef.current = true;
|
||||
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
|
||||
saveVideoHistory({
|
||||
title,
|
||||
config: { platform, aspectRatio, durationSeconds, resolution },
|
||||
plan: planResult as unknown as Record<string, unknown>,
|
||||
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
||||
sourceImageUrls,
|
||||
}).catch(() => {});
|
||||
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
useEffect(() => {
|
||||
if (keepalivePollingStartedRef.current) return;
|
||||
@@ -431,7 +456,7 @@ export default function EcommerceVideoWorkspace({
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderSceneImage(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||
@@ -486,7 +511,7 @@ export default function EcommerceVideoWorkspace({
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||
@@ -529,7 +554,7 @@ export default function EcommerceVideoWorkspace({
|
||||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
@@ -573,6 +598,11 @@ export default function EcommerceVideoWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{onOpenHistory ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||||
<HistoryOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||||
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
@@ -580,12 +610,6 @@ export default function EcommerceVideoWorkspace({
|
||||
<ReloadOutlined /> 继续
|
||||
</button>
|
||||
) : null}
|
||||
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action"
|
||||
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
@@ -619,123 +643,120 @@ export default function EcommerceVideoWorkspace({
|
||||
<section className="ecom-video-flow-canvas" aria-label="视频分镜流程图">
|
||||
{!sourceImage ? (
|
||||
<div className="ecom-video-empty">
|
||||
<span>上传商品图并点击"一键策划"开始</span>
|
||||
<span>上传商品图并点击“一键策划”开始</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ecom-video-flow-map">
|
||||
{/* Source image node */}
|
||||
<article className="ecom-video-flow-node ecom-video-flow-node--source is-ready" aria-label="商品图节点">
|
||||
<div className="ecom-video-flow-node__media">
|
||||
<img src={sourceImage} alt="商品图" />
|
||||
</div>
|
||||
<span className="ecom-video-flow-node__label">商品原图</span>
|
||||
<span className="ecom-video-flow-node__status-orb" aria-hidden="true" />
|
||||
<div className="ecom-video-tree">
|
||||
{/* Source Node — 附件原图 */}
|
||||
<div className="ecom-video-tree__source">
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--source">
|
||||
<img src={sourceImage} alt="商品原图" />
|
||||
</article>
|
||||
|
||||
{/* Connector: source → plan text nodes */}
|
||||
{visiblePlanSteps.length > 0 ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
|
||||
{/* Plan text nodes — side by side */}
|
||||
{visiblePlanSteps.length > 0 ? (
|
||||
<div className="ecom-video-scene-strip ecom-video-scene-strip--text" aria-label="策划节点">
|
||||
{visiblePlanSteps.map((step, idx) => (
|
||||
<Fragment key={step}>
|
||||
<article className={`ecom-video-flow-node ecom-video-flow-node--text is-completed${currentStep === step ? " is-pulsing" : ""}`}
|
||||
aria-label={PLAN_STEP_LABELS[step]} title={PLAN_STEP_LABELS[step]}>
|
||||
<span className="ecom-video-flow-node__text-icon">
|
||||
{currentStep === step ? <LoadingOutlined /> : "✓"}
|
||||
</span>
|
||||
<span className="ecom-video-flow-node__label">{PLAN_STEP_LABELS[step]}</span>
|
||||
</article>
|
||||
{idx < visiblePlanSteps.length - 1 ? (
|
||||
<div className="ecom-video-scene-link is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
<span className="ecom-video-tree-node__label">附件原图</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Connector: plan → images */}
|
||||
{hasImaging ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Storyboard image nodes — side by side per scene */}
|
||||
{hasImaging ? (
|
||||
<div className="ecom-video-scene-strip" aria-label="分镜图片节点">
|
||||
{scenes.map((scene, idx) => {
|
||||
{/* Branches — 每个场景一条分支 */}
|
||||
<div className="ecom-video-tree__rows">
|
||||
{scenes.length > 0 ? scenes.map((scene, idx) => {
|
||||
const planDone = completedSteps.length >= ALL_STEPS.length;
|
||||
const imgReady = !!scene.imageUrl;
|
||||
const imgRunning = stage === "imaging" && (scene.status === "running" || scene.status === "pending") && !scene.imageUrl;
|
||||
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>
|
||||
{imgRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-flow-node__label">分镜{scene.sceneId}</span>
|
||||
<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}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Connector: images → videos */}
|
||||
{hasRendering ? (
|
||||
<div className="ecom-video-flow-connector is-active" aria-hidden="true"><i /></div>
|
||||
) : null}
|
||||
|
||||
{/* Video nodes — side by side per scene */}
|
||||
{hasRendering ? (
|
||||
<div className="ecom-video-scene-strip" aria-label="视频分镜节点">
|
||||
{scenes.map((scene, idx) => {
|
||||
const vidReady = scene.status === "completed" && scene.resultUrl;
|
||||
const vidRunning = stage === "rendering" && (scene.status === "running" || scene.status === "pending");
|
||||
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 key={scene.sceneId} className="ecom-video-tree__row" style={{ animationDelay: `${idx * 120}ms` }}>
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--text${planDone ? " is-completed" : currentStep ? " is-active" : ""}`}>
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜文本{scene.sceneId}</span>
|
||||
<span className="ecom-video-tree-node__desc">
|
||||
{planDone ? "已完成" : stage === "planning" ? "策划中..." : "等待策划"}
|
||||
</span>
|
||||
</div>
|
||||
{vidRunning ? <span className="ecom-video-flow-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-flow-node__label">镜头{scene.sceneId}</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--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
|
||||
{imgReady ? (
|
||||
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||||
) : (
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{imgRunning ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
)}
|
||||
{imgRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-tree-node__tag">分镜图{scene.sceneId}</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${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
|
||||
{vidReady ? (
|
||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||
) : (
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{vidRunning ? <LoadingOutlined /> : vidFailed ? <span>失败</span> : <span>待生成</span>}
|
||||
</div>
|
||||
)}
|
||||
{vidRunning ? <span className="ecom-video-tree-node__progress">{scene.progress || 0}%</span> : null}
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{scene.sceneId}</span>
|
||||
{vidFailed ? (
|
||||
<button type="button" className="ecom-video-flow-node__retry"
|
||||
<button type="button" className="ecom-video-tree-node__retry"
|
||||
onClick={(e) => { e.stopPropagation(); void handleRetryScene(scene); }}
|
||||
title="重试此镜头">
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{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}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
}) : (
|
||||
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
||||
<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">分镜策划</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">分镜图</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">分镜视频</span>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -750,6 +771,19 @@ export default function EcommerceVideoWorkspace({
|
||||
) : null}
|
||||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||||
</section>
|
||||
|
||||
{previewMedia ? (
|
||||
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
|
||||
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
{previewMedia.type === "image" ? (
|
||||
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
|
||||
) : (
|
||||
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
export function normalizeEcommerceImageMime(file: File): string {
|
||||
return file.type || "image/png";
|
||||
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 summarizeRejectedImages(files: File[]): string {
|
||||
if (files.length === 0) return "";
|
||||
return `${files.length} 个文件不符合要求`;
|
||||
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 validateEcommerceImageFiles(files: File[]): {
|
||||
valid: File[];
|
||||
rejected: File[];
|
||||
} {
|
||||
// TODO: implement actual image validation
|
||||
return { valid: files, 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";
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png";
|
||||
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
|
||||
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg";
|
||||
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg";
|
||||
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png";
|
||||
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif";
|
||||
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp";
|
||||
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp";
|
||||
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp";
|
||||
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp";
|
||||
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp";
|
||||
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png";
|
||||
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png";
|
||||
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png";
|
||||
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
|
||||
const [
|
||||
moreTemplateSlide1,
|
||||
moreTemplateSlide2,
|
||||
moreTemplateSlide3,
|
||||
moreTemplateSlide4,
|
||||
moreTemplateSlide5,
|
||||
] = ossAssets.ecommerce.templateSlides;
|
||||
const [
|
||||
ecommerceHeroSlide1,
|
||||
ecommerceHeroSlide2,
|
||||
ecommerceHeroSlide3,
|
||||
ecommerceHeroSlide4,
|
||||
ecommerceHeroSlide5,
|
||||
] = ossAssets.ecommerce.heroSlides;
|
||||
const [
|
||||
ecommerceCarouselImage1,
|
||||
ecommerceCarouselImage2,
|
||||
ecommerceCarouselImage3,
|
||||
ecommerceCarouselImage4,
|
||||
ecommerceCarouselImage5,
|
||||
ecommerceCarouselImage6,
|
||||
] = ossAssets.ecommerce.templateCases;
|
||||
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
|
||||
|
||||
export interface TemplateCase {
|
||||
title: string;
|
||||
@@ -124,6 +134,6 @@ export const templateCases: TemplateCase[] = [
|
||||
title: "促销卖点组合图",
|
||||
category: "详情图",
|
||||
summary: "把成分、规格、卖点拆成清晰的详情页模块。",
|
||||
imageUrl: "https://picsum.photos/id/1080/900/620",
|
||||
imageUrl: ecommerceCarouselImage6,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface RenderSceneImageInput {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
aspectRatio: string;
|
||||
productImageUrls: string[];
|
||||
}
|
||||
|
||||
export interface RenderImageCallbacks {
|
||||
@@ -171,6 +172,7 @@ export async function renderSceneImage(
|
||||
prompt: input.prompt,
|
||||
ratio: input.aspectRatio,
|
||||
quality: "2K",
|
||||
referenceUrls: input.productImageUrls,
|
||||
});
|
||||
|
||||
callbacks.onSceneImageSubmitted(input.sceneId, taskId);
|
||||
@@ -192,6 +194,7 @@ export interface RenderSceneInput {
|
||||
prompt: string;
|
||||
durationSeconds: number;
|
||||
imageUrl: string;
|
||||
productImageUrls: string[];
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
model?: string;
|
||||
@@ -209,9 +212,10 @@ export async function renderScene(
|
||||
callbacks: RenderCallbacks,
|
||||
abortRef: { current: boolean },
|
||||
): Promise<void> {
|
||||
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
|
||||
const model = resolveVideoRequestModel({
|
||||
model: input.model || "happyhorse-1.0",
|
||||
referenceUrls: [input.imageUrl],
|
||||
referenceUrls: allReferenceUrls,
|
||||
});
|
||||
|
||||
const { taskId } = await aiGenerationClient.createVideoTask({
|
||||
@@ -222,7 +226,7 @@ export async function renderScene(
|
||||
quality: input.resolution,
|
||||
resolution: input.resolution,
|
||||
frameMode: "start-end",
|
||||
referenceUrls: [input.imageUrl],
|
||||
referenceUrls: allReferenceUrls,
|
||||
hasReferenceVideo: false,
|
||||
});
|
||||
|
||||
@@ -254,3 +258,73 @@ export function buildSceneTasks(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Video History API ──────────────────────────────────
|
||||
|
||||
export interface VideoHistoryScene {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoHistoryItem {
|
||||
id: number;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VideoHistoryListResponse {
|
||||
items: VideoHistoryItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
import { getStoredToken } from "../../api/serverConnection";
|
||||
|
||||
const API_BASE = "/api/ai/ecommerce/video-history";
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function saveVideoHistory(payload: {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
plan: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
}): Promise<{ id: number; createdAt: string }> {
|
||||
const res = await fetch(API_BASE, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error("保存历史记录失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchVideoHistory(
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<VideoHistoryListResponse> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
if (!res.ok) throw new Error("获取历史记录失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("删除失败");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,752 @@
|
||||
function EcommerceClonePanel(_props: Record<string, unknown>) {
|
||||
return <div style={{ padding: 24 }}>商品克隆模块 - 开发中</div>;
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
CloseOutlined,
|
||||
FileImageOutlined,
|
||||
LoadingOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
||||
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
|
||||
type CloneSetCountKey = "selling" | "white" | "scene";
|
||||
type CloneModelPanelTab = "scene" | "model";
|
||||
type CloneReferenceMode = "upload" | "link";
|
||||
type CloneReplicateLevelKey = "style" | "high";
|
||||
type CloneVideoQualityKey = "standard" | "high" | "ultra";
|
||||
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
|
||||
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
|
||||
|
||||
interface CloneImageItem {
|
||||
id: string;
|
||||
src: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CloneBasicSelectItem {
|
||||
key: CloneBasicSelectKey;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface CloneModelSelectItem {
|
||||
key: CloneModelSelectKey;
|
||||
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: CloneBasicSelectKey | 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: CloneModelSelectKey | null;
|
||||
cloneModelSelectDropUp: boolean;
|
||||
cloneModelAppearance: string;
|
||||
cloneVideoQuality: CloneVideoQualityKey;
|
||||
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
||||
cloneVideoDuration: number;
|
||||
cloneVideoDurationMin: number;
|
||||
cloneVideoDurationMax: number;
|
||||
cloneVideoDurationStyle: CSSProperties;
|
||||
cloneVideoSmart: boolean;
|
||||
canGenerate: boolean;
|
||||
status: string;
|
||||
lastFailedActionRef: MutableRefObject<(() => void) | null>;
|
||||
setIsProductUploadDragging: (value: boolean) => void;
|
||||
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
|
||||
removeProductImage: (id: string) => void;
|
||||
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleCloneOutputChange: (value: CloneOutputKey) => void;
|
||||
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
|
||||
setCloneReferenceMode: (value: CloneReferenceMode) => void;
|
||||
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
|
||||
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
|
||||
clearCloneSetCountHold: () => void;
|
||||
toggleCloneDetailModule: (id: string) => void;
|
||||
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
|
||||
toggleCloneModelScene: (scene: string) => void;
|
||||
setCloneModelCustomScene: (value: string) => void;
|
||||
setOpenCloneModelSelect: (value: CloneModelSelectKey | 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;
|
||||
onStartVideoPlan?: () => 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,
|
||||
onStartVideoPlan,
|
||||
}: 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={5}
|
||||
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>15秒</span>
|
||||
<span>30秒</span>
|
||||
<span>45秒</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" && onStartVideoPlan ? (
|
||||
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
|
||||
✦ 一键策划
|
||||
</button>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default EcommerceClonePanel;
|
||||
|
||||
@@ -1,4 +1,168 @@
|
||||
function EcommerceDetailPanel(_props: Record<string, unknown>) {
|
||||
return <div style={{ padding: 24 }}>商品详情模块 - 开发中</div>;
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default EcommerceDetailPanel;
|
||||
|
||||
@@ -1,4 +1,171 @@
|
||||
function EcommerceSetPanel(_props: Record<string, unknown>) {
|
||||
return <div style={{ padding: 24 }}>商品套图模块 - 开发中</div>;
|
||||
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, DragEvent, RefObject } from "react";
|
||||
|
||||
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
||||
|
||||
interface EcommerceSetPanelProps {
|
||||
setInputRef: RefObject<HTMLInputElement>;
|
||||
setImages: Array<{ id: string; src: string; name: string }>;
|
||||
isSetUploadDragging: boolean;
|
||||
productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }>;
|
||||
productSetOutput: ProductSetOutputKey;
|
||||
platformOptions: string[];
|
||||
marketOptions: string[];
|
||||
productSetLanguageOptions: string[];
|
||||
productSetRatioOptions: string[];
|
||||
productSetPlatform: string;
|
||||
productSetMarket: string;
|
||||
productSetLanguage: string;
|
||||
productSetRatio: string;
|
||||
setIsSetUploadDragging: (value: boolean) => void;
|
||||
handleSetDrop: (event: DragEvent<HTMLButtonElement>) => void;
|
||||
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
removeSetImage: (id: string) => void;
|
||||
handleProductSetOutputChange: (value: ProductSetOutputKey) => 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default EcommerceSetPanel;
|
||||
|
||||
@@ -1,4 +1,219 @@
|
||||
function EcommerceTryOnPanel(_props: Record<string, unknown>) {
|
||||
return <div style={{ padding: 24 }}>AI 试穿模块 - 开发中</div>;
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default EcommerceTryOnPanel;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
fetchVideoHistory,
|
||||
deleteVideoHistory,
|
||||
type VideoHistoryItem,
|
||||
} from "../ecommerceVideoService";
|
||||
|
||||
interface EcommerceVideoHistoryPanelProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceVideoHistoryPanel({
|
||||
visible,
|
||||
onClose,
|
||||
}: EcommerceVideoHistoryPanelProps) {
|
||||
const [items, setItems] = useState<VideoHistoryItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [previewMedia, setPreviewMedia] = useState<{
|
||||
url: string;
|
||||
type: "image" | "video";
|
||||
} | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
const limit = 10;
|
||||
|
||||
const load = useCallback(async (off: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchVideoHistory(limit, off);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setOffset(off);
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) load(0);
|
||||
}, [visible, load]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteVideoHistory(id);
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
setTotal((t) => t - 1);
|
||||
} catch { /* silent */ }
|
||||
setConfirmDeleteId(null);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ecom-video-history-panel">
|
||||
<div className="ecom-video-history-panel__header">
|
||||
<HistoryOutlined />
|
||||
<span>生成记录</span>
|
||||
<button className="ecom-video-history-panel__close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-history-panel__body">
|
||||
{loading && !items.length ? (
|
||||
<div className="ecom-video-history-panel__empty">
|
||||
<LoadingOutlined style={{ fontSize: 24 }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : !items.length ? (
|
||||
<div className="ecom-video-history-panel__empty">
|
||||
<HistoryOutlined style={{ fontSize: 32, opacity: 0.3 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="ecom-video-history-card">
|
||||
<div className="ecom-video-history-card__header">
|
||||
<span className="ecom-video-history-card__title">
|
||||
{item.title || "未命名"}
|
||||
</span>
|
||||
<span className="ecom-video-history-card__date">
|
||||
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
<button
|
||||
className="ecom-video-history-card__delete"
|
||||
onClick={() => setConfirmDeleteId(item.id)}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ecom-video-history-card__scenes">
|
||||
{item.scenes.map((scene, idx) => (
|
||||
<div key={idx} className="ecom-video-history-card__scene">
|
||||
{scene.imageUrl && (
|
||||
<img
|
||||
src={scene.imageUrl}
|
||||
alt={`分镜${idx + 1}`}
|
||||
onClick={() =>
|
||||
setPreviewMedia({ url: scene.imageUrl!, type: "image" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{scene.videoUrl && (
|
||||
<div
|
||||
className="ecom-video-history-card__video-thumb"
|
||||
onClick={() =>
|
||||
setPreviewMedia({ url: scene.videoUrl!, type: "video" })
|
||||
}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="ecom-video-history-panel__pager">
|
||||
<button disabled={currentPage <= 1} onClick={() => load(offset - limit)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>{currentPage}/{totalPages}</span>
|
||||
<button disabled={currentPage >= totalPages} onClick={() => load(offset + limit)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmDeleteId !== null && (
|
||||
<div className="ecom-video-confirm-dialog-backdrop" onClick={() => setConfirmDeleteId(null)}>
|
||||
<div className="ecom-video-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<ExclamationCircleOutlined className="ecom-video-confirm-dialog__icon" />
|
||||
<p className="ecom-video-confirm-dialog__text">
|
||||
确定要删除这条记录吗?相关的图片和视频文件也将被永久删除,此操作不可恢复。
|
||||
</p>
|
||||
<div className="ecom-video-confirm-dialog__actions">
|
||||
<button onClick={() => setConfirmDeleteId(null)}>取消</button>
|
||||
<button className="is-danger" onClick={() => handleDelete(confirmDeleteId)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewMedia && (
|
||||
<div
|
||||
className="ecom-video-preview-overlay"
|
||||
onClick={() => setPreviewMedia(null)}
|
||||
>
|
||||
<button
|
||||
className="ecom-video-preview-overlay__close"
|
||||
onClick={() => setPreviewMedia(null)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
{previewMedia.type === "image" ? (
|
||||
<img src={previewMedia.url} alt="preview" />
|
||||
) : (
|
||||
<video src={previewMedia.url} controls autoPlay />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import WelcomeSplash from "./WelcomeSplash";
|
||||
import ToolboxSection from "./ToolboxSection";
|
||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||
@@ -24,13 +25,12 @@ function ScrollEntrance({ children, className, ...rest }: { children: React.Reac
|
||||
);
|
||||
}
|
||||
|
||||
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
|
||||
const heroImage1 = `${OSS_MUBAN}/hero-1.png`;
|
||||
const heroImage2 = `${OSS_MUBAN}/hero-2.png`;
|
||||
const heroImage3 = `${OSS_MUBAN}/hero-3.png`;
|
||||
const featureEcommerceImage = `${OSS_MUBAN}/feature-ecommerce.jpg`;
|
||||
const featureScriptImage = `${OSS_MUBAN}/feature-script.jpg`;
|
||||
const featureTokenImage = `${OSS_MUBAN}/feature-token.jpg`;
|
||||
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
|
||||
const {
|
||||
ecommerce: featureEcommerceImage,
|
||||
script: featureScriptImage,
|
||||
token: featureTokenImage,
|
||||
} = ossAssets.home.features;
|
||||
|
||||
interface HomePageProps {
|
||||
onOpenGenerate: () => void;
|
||||
@@ -42,7 +42,7 @@ interface HomePageProps {
|
||||
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
|
||||
}
|
||||
|
||||
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/hero-bg.mp4";
|
||||
const HOME_BACKGROUND_VIDEO = ossAssets.home.backgroundVideo;
|
||||
|
||||
const HOME_CAROUSEL_IMAGES = [
|
||||
{ imageUrl: heroImage1, title: "灵感生成" },
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ToolOutlined } from "@ant-design/icons";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import toolImageBefore from "../../assets/toolbox/牛仔.png";
|
||||
import toolImageAfter from "../../assets/toolbox/西装.png";
|
||||
import watermarkBefore from "../../assets/toolbox/去水印前.png";
|
||||
import watermarkAfter from "../../assets/toolbox/去水印后.png";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
|
||||
const {
|
||||
imageBefore: toolImageBefore,
|
||||
imageAfter: toolImageAfter,
|
||||
watermarkBefore,
|
||||
watermarkAfter,
|
||||
} = ossAssets.toolbox;
|
||||
|
||||
interface ToolboxSectionProps {
|
||||
onSelectView: (view: WebViewKey) => void;
|
||||
|
||||
@@ -148,22 +148,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
keepaliveRestoredRef.current = true;
|
||||
const saved = loadToolTaskState("imagewb");
|
||||
if (!saved || saved.resultUrl) return;
|
||||
setIsGenerating(true);
|
||||
setGenerating(true);
|
||||
abortRef.current = false;
|
||||
taskIdRef.current = saved.taskId;
|
||||
void waitForTask(saved.taskId, {
|
||||
onProgress: (e) => {
|
||||
setTaskProgress(Math.max(0, Math.min(100, Math.trunc(e.progress || 0))));
|
||||
setStatus(`${e.status} / ${e.progress}%`);
|
||||
if (e.status === "completed" && e.resultUrl) {
|
||||
setResultImages([e.resultUrl]);
|
||||
clearToolTaskState("imagewb");
|
||||
setIsGenerating(false);
|
||||
setGenerating(false);
|
||||
setStatus("恢复任务完成");
|
||||
}
|
||||
if (e.status === "failed") {
|
||||
clearToolTaskState("imagewb");
|
||||
setIsGenerating(false);
|
||||
setGenerating(false);
|
||||
setStatus("恢复任务失败");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@ import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||
import { keyServerClient } from "../../api/keyServerClient";
|
||||
import { isServerRequestError } from "../../api/serverConnection";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
|
||||
import type { SavedAssetItem } from "../assets/localAssetStore";
|
||||
|
||||
@@ -44,8 +45,8 @@ type ProfilePanel = "works" | "projects" | "assets" | "community";
|
||||
type AccountPanel = "credits" | "tasks";
|
||||
|
||||
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
|
||||
const AUTH_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
|
||||
const AUTH_SHOWCASE_VIDEO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test5.mp4";
|
||||
const AUTH_LOGO_URL = ossAssets.brand.logo;
|
||||
const AUTH_SHOWCASE_VIDEO_URL = ossAssets.auth.showcaseVideo;
|
||||
|
||||
function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
|
||||
return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "@ant-design/icons";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
|
||||
import { reportClient, type ReportInput } from "../../api/reportClient";
|
||||
|
||||
type SubmitState = "idle" | "loading" | "success" | "error";
|
||||
@@ -31,6 +32,7 @@ function ReportPage() {
|
||||
const [contactPhone, setContactPhone] = useState("");
|
||||
const [submitState, setSubmitState] = useState<SubmitState>("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
||||
|
||||
const canSubmit =
|
||||
submitState !== "loading" && reportType !== "" && title.trim() !== "" && description.trim() !== "";
|
||||
@@ -48,6 +50,22 @@ function ReportPage() {
|
||||
setErrorMsg("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
publicConfigClient
|
||||
.get()
|
||||
.then((config) => {
|
||||
if (!cancelled) setPublicConfig(config);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicConfig({});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
@@ -85,9 +103,9 @@ function ReportPage() {
|
||||
</header>
|
||||
|
||||
<div className="report-contact-strip">
|
||||
<span><MailOutlined /> {import.meta.env.VITE_REPORT_EMAIL || "support@omniai.com"}</span>
|
||||
<span><PhoneOutlined /> {import.meta.env.VITE_REPORT_PHONE || "请在环境变量配置客服电话"}</span>
|
||||
<span>{import.meta.env.VITE_ICP_RECORD || "ICP备案信息待配置"}</span>
|
||||
<span><MailOutlined /> {publicConfig.contactEmail || "由服务器配置"}</span>
|
||||
<span><PhoneOutlined /> {publicConfig.contactPhone || "由服务器配置"}</span>
|
||||
<span>{publicConfig.icpRecord || "由服务器配置"}</span>
|
||||
</div>
|
||||
|
||||
{submitState === "success" ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
||||
import { useSessionStore } from "../../stores";
|
||||
|
||||
interface ScoreDimension {
|
||||
@@ -175,61 +176,6 @@ function normalizeUploadedText(raw: string, ext: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function extractDocxText(bytes: Uint8Array): Promise<string> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
|
||||
let pos = 0;
|
||||
while (pos < bytes.length - 30) {
|
||||
if (view.getUint32(pos, true) !== 0x04034b50) break;
|
||||
const compressed = view.getUint16(pos + 10, true) !== 0;
|
||||
const compressedSize = view.getUint32(pos + 18, true);
|
||||
const fileNameLen = view.getUint16(pos + 26, true);
|
||||
const extraLen = view.getUint16(pos + 28, true);
|
||||
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
|
||||
const dataStart = pos + 30 + fileNameLen + extraLen;
|
||||
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
|
||||
pos = dataStart + compressedSize;
|
||||
}
|
||||
const docEntry = entries.find((e) => e.name === "word/document.xml");
|
||||
if (!docEntry) return "";
|
||||
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
|
||||
let xmlText: string;
|
||||
if (docEntry.compressed) {
|
||||
try {
|
||||
const ds = new DecompressionStream("deflate-raw");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(xmlBytes);
|
||||
writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const combined = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
|
||||
xmlText = new TextDecoder().decode(combined);
|
||||
} catch {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
} else {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!textMatches) return "";
|
||||
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
|
||||
if (paraMatches) {
|
||||
return paraMatches.map((p) => {
|
||||
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!tMatches) return "";
|
||||
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
|
||||
}).filter(Boolean).join("\n").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
@@ -321,22 +267,26 @@ function ScriptTokensPage() {
|
||||
const ext = getFileExtension(file.name);
|
||||
const readable = isReadableTextFile(file, ext);
|
||||
setUploadedFile({ name: file.name, size: file.size });
|
||||
if (ext === ".docx") {
|
||||
if (ext === ".docx" || ext === ".doc") {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const text = await extractDocxText(bytes);
|
||||
if (text) {
|
||||
setScript(text);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const token = getStoredToken();
|
||||
const resp = await fetch(buildApiUrl("files/extract-text"), {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (resp.ok) {
|
||||
const { text } = await resp.json();
|
||||
setScript(text || "");
|
||||
} else {
|
||||
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
||||
const err = await resp.json().catch(() => ({ error: "解析失败" }));
|
||||
setScript(`[已上传文件:${file.name}]\n\n${err.error || "文件解析失败,请尝试另存为 TXT 格式后重新上传。"}`);
|
||||
}
|
||||
} catch {
|
||||
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
||||
setScript(`[已上传文件:${file.name}]\n\n文件解析请求失败,请检查网络连接后重试。`);
|
||||
}
|
||||
} else if (ext === ".doc") {
|
||||
const text = await decodeTextFile(file);
|
||||
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
|
||||
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
|
||||
} else if (readable) {
|
||||
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
||||
setScript(text);
|
||||
|
||||
@@ -142,6 +142,8 @@ function TokenUsagePage({
|
||||
onSelectView,
|
||||
}: TokenUsagePageProps) {
|
||||
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 isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||
|
||||
@@ -152,10 +154,15 @@ function TokenUsagePage({
|
||||
setEnterpriseUsage(null);
|
||||
return;
|
||||
}
|
||||
setEnterpriseUsageLoading(true);
|
||||
setEnterpriseUsageError(null);
|
||||
try {
|
||||
setEnterpriseUsage(await loader());
|
||||
} catch (error) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(error instanceof Error ? error.message : "加载失败");
|
||||
} finally {
|
||||
setEnterpriseUsageLoading(false);
|
||||
}
|
||||
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||
|
||||
|
||||
@@ -237,7 +237,6 @@ function WorkbenchPage({
|
||||
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
|
||||
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const scrollActionsHideTimerRef = useRef<number | null>(null);
|
||||
const shouldFollowNewMessagesRef = useRef(true);
|
||||
const pendingScrollToLatestRef = useRef(true);
|
||||
const genTracker = useGenerationTasks({ sourceView: "workbench" });
|
||||
@@ -278,8 +277,6 @@ function WorkbenchPage({
|
||||
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
||||
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
|
||||
const [composerHidden, setComposerHidden] = useState(false);
|
||||
const [scrollActionsVisible, setScrollActionsVisible] = useState(false);
|
||||
const [scrollActionDirection, setScrollActionDirection] = useState<"top" | "bottom" | null>(null);
|
||||
const [workspaceStarted, setWorkspaceStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -448,27 +445,6 @@ function WorkbenchPage({
|
||||
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
|
||||
} as CSSProperties;
|
||||
|
||||
const revealScrollActionsTemporarily = useCallback((direction: "top" | "bottom") => {
|
||||
setScrollActionDirection(direction);
|
||||
setScrollActionsVisible(true);
|
||||
if (scrollActionsHideTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollActionsHideTimerRef.current);
|
||||
}
|
||||
scrollActionsHideTimerRef.current = window.setTimeout(() => {
|
||||
setScrollActionsVisible(false);
|
||||
setScrollActionDirection(null);
|
||||
scrollActionsHideTimerRef.current = null;
|
||||
}, 950);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollActionsHideTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollActionsHideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
const scroll = () => {
|
||||
const surface = messagesSurfaceRef.current;
|
||||
@@ -479,7 +455,6 @@ function WorkbenchPage({
|
||||
|
||||
setComposerHidden(false);
|
||||
shouldFollowNewMessagesRef.current = true;
|
||||
revealScrollActionsTemporarily("bottom");
|
||||
surface.scrollTo({ top: surface.scrollHeight, behavior });
|
||||
lastScrollTopRef.current = surface.scrollTop;
|
||||
};
|
||||
@@ -488,7 +463,7 @@ function WorkbenchPage({
|
||||
scroll();
|
||||
window.setTimeout(scroll, 80);
|
||||
});
|
||||
}, [revealScrollActionsTemporarily]);
|
||||
}, []);
|
||||
|
||||
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
|
||||
() => [
|
||||
@@ -1026,11 +1001,6 @@ function WorkbenchPage({
|
||||
});
|
||||
removeKeepaliveTask(task.taskId);
|
||||
onRefreshUsage?.();
|
||||
if (status.status === "completed") {
|
||||
import("../../utils/generationNotifier").then((m) =>
|
||||
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1402,9 +1372,6 @@ function WorkbenchPage({
|
||||
const delta = top - lastScrollTopRef.current;
|
||||
const atTop = top <= edgeThreshold;
|
||||
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
|
||||
if (surface.scrollHeight > surface.clientHeight + edgeThreshold && Math.abs(delta) > 1) {
|
||||
revealScrollActionsTemporarily(delta > 0 ? "bottom" : "top");
|
||||
}
|
||||
shouldFollowNewMessagesRef.current = atBottom;
|
||||
if (atTop || atBottom) {
|
||||
setComposerHidden(false);
|
||||
@@ -1416,7 +1383,7 @@ function WorkbenchPage({
|
||||
|
||||
surface.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => surface.removeEventListener("scroll", handleScroll);
|
||||
}, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
|
||||
}, [hasActivatedWorkspace]);
|
||||
|
||||
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
|
||||
const surface = messagesSurfaceRef.current;
|
||||
@@ -1424,9 +1391,8 @@ function WorkbenchPage({
|
||||
|
||||
const top = direction === "top" ? 0 : surface.scrollHeight;
|
||||
setComposerHidden(false);
|
||||
revealScrollActionsTemporarily(direction);
|
||||
surface.scrollTo({ top, behavior: "smooth" });
|
||||
}, [revealScrollActionsTemporarily]);
|
||||
}, []);
|
||||
|
||||
const closeToolbarMenus = () => setToolbarMenuId(null);
|
||||
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
|
||||
@@ -3115,13 +3081,10 @@ function WorkbenchPage({
|
||||
{renderComposerToolbar(false, isGenerating)}
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
|
||||
aria-label="聊天滚动"
|
||||
>
|
||||
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
|
||||
<button
|
||||
type="button"
|
||||
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
|
||||
className="wb-chat-scroll-actions__button"
|
||||
title="返回聊天顶部"
|
||||
aria-label="返回聊天顶部"
|
||||
onClick={() => scrollMessagesSurface("top")}
|
||||
@@ -3130,7 +3093,7 @@ function WorkbenchPage({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
|
||||
className="wb-chat-scroll-actions__button"
|
||||
title="到达聊天底部"
|
||||
aria-label="到达聊天底部"
|
||||
onClick={() => scrollMessagesSurface("bottom")}
|
||||
|
||||
@@ -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(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setStatus("generating");
|
||||
setError(null);
|
||||
abortRef.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 = true; }, []);
|
||||
|
||||
return {
|
||||
status, error, abortRef, start, succeed, fail, reset, cancel,
|
||||
isGenerating: status === "generating",
|
||||
isFailed: status === "failed",
|
||||
isIdle: status === "idle",
|
||||
};
|
||||
}
|
||||
@@ -1,12 +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;
|
||||
sourceView: string;
|
||||
autoResume?: boolean;
|
||||
}
|
||||
|
||||
export function useGenerationTasks(_options?: UseGenerationTasksOptions) {
|
||||
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: [],
|
||||
queue: [],
|
||||
isRunning: false,
|
||||
progress: null,
|
||||
tasks: myTasks,
|
||||
activeTasks,
|
||||
completedTasks,
|
||||
failedTasks,
|
||||
submitTask,
|
||||
updateTask,
|
||||
markCompleted,
|
||||
markFailed,
|
||||
retryTask,
|
||||
hasActiveTasks: activeTasks.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,128 @@
|
||||
export function recoverAndResumeTasks() {
|
||||
// TODO: implement background task recovery
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface GenerationQueueItem {
|
||||
progress: number;
|
||||
prompt: string;
|
||||
createdAt: number;
|
||||
sourceView: string;
|
||||
sourceView: string; // which page created this: "ecommerce", "workbench", "canvas", "agent"
|
||||
resultUrl?: string | null;
|
||||
error?: string | null;
|
||||
params?: Record<string, unknown>;
|
||||
@@ -26,7 +26,7 @@ interface PersistedQueueSnapshot {
|
||||
|
||||
const STORAGE_KEY = "omniai:generation-queue";
|
||||
const MAX_ITEMS = 80;
|
||||
const STALE_MS = 2 * 60 * 60 * 1000;
|
||||
const STALE_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
function loadPersistedQueue(): GenerationQueueItem[] {
|
||||
try {
|
||||
@@ -63,6 +63,17 @@ interface GenerationStoreState {
|
||||
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) => ({
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
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-loading-center {
|
||||
|
||||
@@ -179,7 +179,10 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #101318;
|
||||
padding: 26px;
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ecom-video-flow-map {
|
||||
@@ -499,6 +502,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #697486;
|
||||
font-size: 13px;
|
||||
@@ -708,4 +712,702 @@
|
||||
.ecom-video-flow-node--scene {
|
||||
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: stretch;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ── Source node ── */
|
||||
.ecom-video-tree__source {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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: 180px;
|
||||
height: 230px;
|
||||
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: 140px;
|
||||
max-width: 170px;
|
||||
padding: 16px 14px;
|
||||
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: 170px;
|
||||
height: 136px;
|
||||
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: 56px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.ecom-video-tree__trunk-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
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: 28px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ecom-video-tree__branch-tap::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #3a4550;
|
||||
}
|
||||
|
||||
.ecom-video-tree__branch-tap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
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;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ecom-video-tree__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
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%); }
|
||||
}
|
||||
|
||||
/* ── Preview lightbox overlay ────────────────────── */
|
||||
.ecom-video-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: zoom-out;
|
||||
animation: ecom-preview-fade-in 200ms ease;
|
||||
}
|
||||
|
||||
.ecom-video-preview-overlay__close {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-preview-overlay img,
|
||||
.ecom-video-preview-overlay video {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@keyframes ecom-preview-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── History panel ──────────────────────────────── */
|
||||
|
||||
.ecom-video-history-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
height: 100vh;
|
||||
background: #1a1d24;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||
animation: ecom-history-slide-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ecom-history-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__close {
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__title {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__date {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__delete {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__delete:hover {
|
||||
background: rgba(255, 80, 80, 0.15);
|
||||
color: #ff5050;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scenes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scene {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scene img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scene img:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__video-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__video-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager button {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Delete confirmation dialog ─────────────────── */
|
||||
|
||||
.ecom-video-confirm-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 12px;
|
||||
background: #1e2128;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__icon {
|
||||
font-size: 36px;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button {
|
||||
padding: 6px 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button.is-danger {
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button.is-danger:hover {
|
||||
background: #ff7875;
|
||||
border-color: #ff7875;
|
||||
}
|
||||
|
||||
@@ -2831,10 +2831,10 @@
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 300px) 54px minmax(330px, 560px);
|
||||
grid-template-columns: minmax(260px, 380px) 54px minmax(400px, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: min(100%, 960px);
|
||||
gap: 28px;
|
||||
width: min(100%, 1120px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result,
|
||||
@@ -2842,24 +2842,26 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid #2c3038;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
background: #1b1d23;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
border-color 200ms ease,
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result:hover,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover {
|
||||
border-color: #00ff88;
|
||||
transform: translateY(-1px);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 24px rgba(0, 255, 136, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result:active,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result img,
|
||||
@@ -2868,39 +2870,46 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result:hover img,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result {
|
||||
height: 360px;
|
||||
height: 440px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
|
||||
height: 172px;
|
||||
height: 210px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:first-child {
|
||||
grid-column: 1 / -1;
|
||||
height: 190px;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result span,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-result-grid span {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 11px;
|
||||
max-width: calc(100% - 22px);
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
max-width: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
border: 1px solid #303540;
|
||||
border: 1px solid rgba(48, 53, 64, 0.6);
|
||||
border-radius: 999px;
|
||||
background: #15171c;
|
||||
background: rgba(21, 23, 28, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #d8deed;
|
||||
padding: 6px 10px;
|
||||
padding: 7px 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
@@ -3000,6 +3009,346 @@
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
@@ -3153,13 +3502,7 @@
|
||||
.product-clone-uploaded-thumb:hover .uploaded-image-zoom,
|
||||
.product-clone-uploaded-thumb:focus-within .uploaded-image-zoom,
|
||||
.clone-ai-replicate-preview figure:hover .uploaded-image-zoom,
|
||||
.clone-ai-replicate-preview figure:focus-within .uploaded-image-zoom,
|
||||
.product-set-main-card:hover .uploaded-image-zoom,
|
||||
.product-set-main-card:focus-within .uploaded-image-zoom,
|
||||
.clone-ai-main-result:hover .uploaded-image-zoom,
|
||||
.clone-ai-main-result:focus-within .uploaded-image-zoom,
|
||||
.product-detail-source-stack figure:hover .uploaded-image-zoom,
|
||||
.product-detail-source-stack figure:focus-within .uploaded-image-zoom {
|
||||
.clone-ai-replicate-preview figure:focus-within .uploaded-image-zoom {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
visibility: visible;
|
||||
@@ -3459,8 +3802,8 @@
|
||||
.product-clone-thumb-row,
|
||||
.product-clone-ref-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.product-clone-thumb-row {
|
||||
@@ -3471,7 +3814,13 @@
|
||||
.product-clone-ref-grid img {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
|
||||
.product-clone-thumb-row img:hover,
|
||||
.product-clone-ref-grid img:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.product-clone-thumb-row img {
|
||||
@@ -3655,12 +4004,12 @@
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 34px;
|
||||
gap: 36px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #f5f6f8;
|
||||
padding: 42px;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.product-clone-preview__headline {
|
||||
@@ -3684,21 +4033,29 @@
|
||||
.product-clone-demo-board {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 340px) 44px minmax(300px, 360px);
|
||||
grid-template-columns: minmax(300px, 400px) 48px minmax(340px, 420px);
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
width: min(100%, 780px);
|
||||
border-radius: 22px;
|
||||
gap: 34px;
|
||||
width: min(100%, 920px);
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
padding: 30px;
|
||||
padding: 34px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.product-clone-source-card,
|
||||
.product-clone-result-stack figure {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
background: #f2f4f7;
|
||||
transition: transform 250ms ease, box-shadow 250ms ease;
|
||||
}
|
||||
|
||||
.product-clone-source-card:hover,
|
||||
.product-clone-result-stack figure:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.product-clone-source-card img,
|
||||
@@ -3707,16 +4064,23 @@
|
||||
width: 100%;
|
||||
aspect-ratio: 1.55;
|
||||
object-fit: cover;
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.product-clone-source-card:hover img,
|
||||
.product-clone-result-stack figure:hover img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.product-clone-source-card span,
|
||||
.product-clone-result-stack figcaption {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 7px 13px;
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -3765,7 +4129,7 @@
|
||||
|
||||
.product-clone-result-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-clone-result-stack figure {
|
||||
@@ -4515,19 +4879,25 @@
|
||||
|
||||
.product-set-demo-board {
|
||||
display: grid;
|
||||
grid-template-columns: 336px 40px 338px;
|
||||
grid-template-columns: minmax(300px, 420px) 44px minmax(340px, 1fr);
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: min(100%, 802px);
|
||||
min-height: 336px;
|
||||
gap: 28px;
|
||||
width: min(100%, 960px);
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.product-set-demo-board figure {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
transition: transform 250ms ease, box-shadow 250ms ease;
|
||||
}
|
||||
|
||||
.product-set-demo-board figure:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.product-set-demo-board img {
|
||||
@@ -4535,6 +4905,11 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.product-set-demo-board figure:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.product-set-demo-board figcaption {
|
||||
@@ -4544,10 +4919,11 @@
|
||||
max-width: calc(100% - 24px);
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
color: #111827;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -4555,25 +4931,32 @@
|
||||
|
||||
.product-set-main-card {
|
||||
position: relative;
|
||||
height: 336px;
|
||||
height: 380px;
|
||||
border-radius: 16px;
|
||||
transition: transform 250ms ease, box-shadow 250ms ease;
|
||||
}
|
||||
|
||||
.product-set-main-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.product-set-flow-arrow {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #b8c3d1;
|
||||
background: linear-gradient(90deg, #b8c3d1, #d7dde6);
|
||||
clip-path: polygon(0 28%, 58% 28%, 58% 0, 100% 50%, 58% 100%, 58% 72%, 0 72%);
|
||||
}
|
||||
|
||||
.product-set-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-set-card-grid figure {
|
||||
height: 162px;
|
||||
height: 184px;
|
||||
}
|
||||
|
||||
.product-set-generated-note {
|
||||
@@ -5027,13 +5410,13 @@
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-demo-board {
|
||||
grid-template-columns: minmax(360px, 486px) 44px minmax(360px, 486px);
|
||||
gap: 28px;
|
||||
width: min(100%, 1150px);
|
||||
min-height: 576px;
|
||||
grid-template-columns: minmax(380px, 1fr) 48px minmax(380px, 1fr);
|
||||
gap: 32px;
|
||||
width: min(100%, 1200px);
|
||||
min-height: 620px;
|
||||
border-radius: 32px;
|
||||
background: #ffffff;
|
||||
padding: 37px 30px;
|
||||
padding: 40px 34px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-demo-board figure {
|
||||
@@ -5043,16 +5426,16 @@
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-main-card {
|
||||
height: 502px;
|
||||
height: 540px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-card-grid {
|
||||
gap: 18px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-card-grid figure {
|
||||
height: 242px;
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-demo-board figcaption {
|
||||
@@ -5585,9 +5968,8 @@
|
||||
}
|
||||
|
||||
.product-detail-source-stack figure {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
@@ -6508,26 +6890,37 @@
|
||||
.product-try-on-generated {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
width: min(100%, 766px);
|
||||
border-radius: 18px;
|
||||
gap: 14px;
|
||||
width: min(100%, 820px);
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.product-try-on-generated figure {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: #edf1f6;
|
||||
transition: transform 250ms ease, box-shadow 250ms ease;
|
||||
}
|
||||
|
||||
.product-try-on-generated figure:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.product-try-on-generated img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.product-try-on-generated figure:hover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.product-try-on-generated figcaption {
|
||||
@@ -6535,8 +6928,9 @@
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
padding: 5px 10px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 6px 12px;
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -7192,15 +7586,27 @@
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: 1px solid #dfe5ee;
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
background: #f5f6f8;
|
||||
aspect-ratio: 1;
|
||||
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.product-set-thumb:hover {
|
||||
border-color: #c6cdd8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.product-set-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 250ms ease;
|
||||
}
|
||||
|
||||
.product-set-thumb:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.product-set-thumb button {
|
||||
@@ -7208,12 +7614,13 @@
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
border: 1px solid #dfe5ee;
|
||||
border: 1px solid rgba(223, 229, 238, 0.7);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
color: #111827;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -8090,7 +8497,6 @@
|
||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card img {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.clone-ai-video-outfit-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -8977,31 +9383,3 @@
|
||||
padding-top: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Page Drag-and-Drop Overlay ─── */
|
||||
.product-clone-page.is-page-dragging {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ecommerce-drag-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px dashed rgba(0, 255, 136, 0.5);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 255, 136, 0.06);
|
||||
color: rgba(0, 255, 136, 0.9);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
animation: ecommerce-drag-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ecommerce-drag-pulse {
|
||||
0%, 100% { border-color: rgba(0, 255, 136, 0.5); }
|
||||
50% { border-color: rgba(0, 255, 136, 0.8); }
|
||||
}
|
||||
|
||||
@@ -148,83 +148,37 @@
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
padding: 0 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(20, 23, 26, 0.72) 0%, rgba(15, 17, 19, 0.84) 100%);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 2px 8px rgba(0, 0, 0, 0.28);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--fg-body);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
transition:
|
||||
border-color 240ms ease,
|
||||
background 240ms ease,
|
||||
color 240ms ease,
|
||||
transform 240ms cubic-bezier(0.34, 1.2, 0.64, 1),
|
||||
box-shadow 240ms ease;
|
||||
font-size: 17px;
|
||||
font-weight: 850;
|
||||
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.omni-home__entry .anticon {
|
||||
font-size: 19px;
|
||||
transition: color 240ms ease, transform 240ms ease;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.omni-home__entry:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: linear-gradient(180deg, rgba(28, 32, 36, 0.78) 0%, rgba(18, 22, 25, 0.88) 100%);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.06) inset,
|
||||
0 0 24px rgba(var(--accent-rgb), 0.06),
|
||||
0 4px 16px rgba(0, 0, 0, 0.36);
|
||||
border-color: var(--border-default);
|
||||
background: var(--bg-hover);
|
||||
color: #ffffff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.omni-home__entry:hover .anticon {
|
||||
color: var(--accent);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.omni-home__entry:active {
|
||||
transform: translateY(0) scale(0.97);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.02) inset,
|
||||
0 1px 4px rgba(0, 0, 0, 0.32);
|
||||
transition-duration: 80ms;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.omni-home__entry--primary {
|
||||
border-color: rgba(var(--accent-rgb), 0.48);
|
||||
background: linear-gradient(180deg, rgba(0, 255, 136, 0.22) 0%, rgba(0, 220, 118, 0.14) 100%), var(--accent);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.12) inset,
|
||||
0 0 28px rgba(var(--accent-rgb), 0.18),
|
||||
0 2px 12px rgba(0, 0, 0, 0.28);
|
||||
color: #061014;
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--dg-button-text, #061014);
|
||||
}
|
||||
|
||||
.omni-home__entry--primary:hover {
|
||||
border-color: rgba(var(--accent-rgb), 0.64);
|
||||
background: linear-gradient(180deg, rgba(0, 255, 136, 0.28) 0%, rgba(0, 230, 124, 0.18) 100%), var(--accent-hover);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.16) inset,
|
||||
0 0 40px rgba(var(--accent-rgb), 0.28),
|
||||
0 6px 24px rgba(0, 0, 0, 0.36);
|
||||
color: #061014;
|
||||
}
|
||||
|
||||
.omni-home__entry--primary .anticon {
|
||||
color: #061014;
|
||||
}
|
||||
|
||||
.omni-home__entry--primary:hover .anticon {
|
||||
color: #061014;
|
||||
transform: scale(1.12);
|
||||
border-color: var(--accent-hover, var(--accent));
|
||||
background: var(--accent-hover, var(--accent));
|
||||
color: var(--dg-button-text, #061014);
|
||||
}
|
||||
|
||||
.omni-home__carousel {
|
||||
|
||||
@@ -596,14 +596,27 @@ textarea.image-workbench-prompt {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-dim);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--fg-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-workbench-empty .anticon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
font-size: 40px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.image-workbench-empty strong {
|
||||
font-size: 18px;
|
||||
color: var(--fg-body, #eee);
|
||||
}
|
||||
|
||||
.image-workbench-empty span {
|
||||
max-width: 320px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.image-workbench-empty--button {
|
||||
@@ -824,22 +837,24 @@ textarea.image-workbench-prompt {
|
||||
|
||||
.image-workbench-panel--right .image-workbench-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.image-workbench-result-thumb {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
aspect-ratio: 1;
|
||||
transition: border-color 0.15s;
|
||||
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.image-workbench-result-thumb:hover {
|
||||
border-color: var(--accent, #2dd4bf);
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.image-workbench-result-thumb img {
|
||||
@@ -1598,30 +1613,30 @@ textarea.image-workbench-prompt {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.image-workbench-generating strong {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
color: var(--fg-default);
|
||||
}
|
||||
|
||||
.image-workbench-progress-bar {
|
||||
width: 320px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
width: min(420px, 80%);
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-inset);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-workbench-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.image-workbench-cancel {
|
||||
@@ -1642,30 +1657,30 @@ textarea.image-workbench-prompt {
|
||||
}
|
||||
|
||||
.image-workbench-result-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.image-workbench-result-item {
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-weak);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.image-workbench-result-item:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(var(--accent-rgb, 45, 212, 191), 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.image-workbench-result-item img {
|
||||
@@ -1674,20 +1689,26 @@ textarea.image-workbench-prompt {
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: var(--bg-inset);
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.image-workbench-result-item:hover img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-workbench-result-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
width: min(100%, 500px);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.image-workbench-result-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.image-workbench-result-actions button {
|
||||
@@ -1735,3 +1756,23 @@ textarea.image-workbench-prompt {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Result card entrance animation */
|
||||
@keyframes image-workbench-result-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.image-workbench-result-card {
|
||||
animation: image-workbench-result-enter 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.image-workbench-result-card:nth-child(2) { animation-delay: 0.08s; }
|
||||
.image-workbench-result-card:nth-child(3) { animation-delay: 0.16s; }
|
||||
.image-workbench-result-card:nth-child(4) { animation-delay: 0.24s; }
|
||||
|
||||
@@ -3400,7 +3400,6 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
max-height: 520px;
|
||||
padding: 18px 22px;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -3410,7 +3409,6 @@
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.script-eval-v4-text-input::placeholder {
|
||||
@@ -4270,11 +4268,6 @@
|
||||
.script-eval-v4-text-shell,
|
||||
.script-eval-v4-text-input {
|
||||
min-height: calc(100vh - 422px);
|
||||
max-height: calc(100vh - 422px);
|
||||
}
|
||||
|
||||
.script-eval-v4-text-input {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.script-eval-v4-score-card {
|
||||
@@ -5377,559 +5370,3 @@
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ===== Token usage commercial SaaS polish ===== */
|
||||
.token-usage-page.management-center-page {
|
||||
--usage-panel: rgba(17, 21, 21, 0.96);
|
||||
--usage-panel-strong: rgba(21, 26, 25, 0.98);
|
||||
--usage-inset: rgba(255, 255, 255, 0.035);
|
||||
--usage-inset-strong: rgba(255, 255, 255, 0.055);
|
||||
--usage-line: rgba(255, 255, 255, 0.08);
|
||||
--usage-line-strong: rgba(var(--accent-rgb), 0.28);
|
||||
--usage-muted: rgba(232, 240, 235, 0.66);
|
||||
--usage-soft: rgba(232, 240, 235, 0.44);
|
||||
--usage-card-shadow: 0 18px 46px rgba(0, 0, 0, 0.22);
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(var(--accent-rgb), 0.06), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.022), transparent 220px),
|
||||
var(--bg-base);
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-shell {
|
||||
gap: 16px;
|
||||
padding: 0 30px 42px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 8;
|
||||
min-height: 64px;
|
||||
border-bottom-color: var(--usage-line);
|
||||
border-bottom-left-radius: 18px;
|
||||
background: rgba(14, 17, 17, 0.88);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar__title {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar__title > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar__title strong {
|
||||
color: #f2f8f5;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar__title small {
|
||||
overflow: hidden;
|
||||
color: var(--usage-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar button,
|
||||
.token-usage-page .management-card__head button,
|
||||
.token-usage-page .management-center-status-pill {
|
||||
border-color: var(--usage-line);
|
||||
border-radius: 10px;
|
||||
background: var(--usage-inset);
|
||||
color: var(--usage-muted);
|
||||
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar button:hover:not(:disabled),
|
||||
.token-usage-page .management-card__head button:hover {
|
||||
border-color: var(--usage-line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
color: var(--fg-body);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar button:disabled {
|
||||
opacity: 0.52;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar__back {
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar button.is-muted-action {
|
||||
color: var(--usage-soft);
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar button.is-primary {
|
||||
border-color: rgba(var(--accent-rgb), 0.72);
|
||||
background: linear-gradient(180deg, #2fffa5, var(--accent));
|
||||
color: rgb(5, 15, 11);
|
||||
box-shadow: 0 12px 26px rgba(var(--accent-rgb), 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill {
|
||||
position: relative;
|
||||
gap: 7px;
|
||||
border-radius: 999px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px rgba(var(--accent-rgb), 0.45);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill.is-loading::before {
|
||||
animation: token-usage-pulse 1.2s ease infinite;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill.is-error {
|
||||
border-color: rgba(245, 158, 11, 0.42);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f7ca73;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill.is-error::before {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 14px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
@keyframes token-usage-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.42; transform: scale(0.76); }
|
||||
}
|
||||
|
||||
.token-usage-page .management-balance-alert {
|
||||
margin: 2px 0 0;
|
||||
border-color: rgba(245, 158, 11, 0.34);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(245, 158, 11, 0.14), rgba(245, 158, 11, 0.045)),
|
||||
var(--usage-panel);
|
||||
box-shadow: var(--usage-card-shadow);
|
||||
}
|
||||
|
||||
.token-usage-page .management-balance-alert button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-cards {
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card {
|
||||
position: relative;
|
||||
min-height: 132px;
|
||||
overflow: hidden;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-color: var(--usage-line);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.048), transparent 70%),
|
||||
var(--usage-panel-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card.is-accent {
|
||||
border-color: rgba(var(--accent-rgb), 0.32);
|
||||
background:
|
||||
radial-gradient(circle at 88% 16%, rgba(var(--accent-rgb), 0.18), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.052), transparent 72%),
|
||||
var(--usage-panel-strong);
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card.is-accent::before {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.44);
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card.is-warn::before {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 18px rgba(245, 158, 11, 0.32);
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card__index {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 16px;
|
||||
color: rgba(255, 255, 255, 0.14);
|
||||
font-size: 22px;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card__label {
|
||||
color: var(--usage-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card__value {
|
||||
color: #f6fbf8;
|
||||
font-size: clamp(24px, 2.5vw, 34px);
|
||||
font-weight: 920;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card__hint {
|
||||
color: var(--usage-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-overview {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-card {
|
||||
border-color: var(--usage-line);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 64%),
|
||||
var(--usage-panel);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.026);
|
||||
}
|
||||
|
||||
.token-usage-page .management-card__head {
|
||||
min-height: 50px;
|
||||
border-bottom-color: var(--usage-line);
|
||||
padding-inline: 18px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-card__head h2 {
|
||||
color: #ecf5f0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-card__head h2 .anticon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.token-usage-page .management-card__head > span,
|
||||
.token-usage-page .management-card__head button {
|
||||
border-color: var(--usage-line);
|
||||
border-radius: 999px;
|
||||
background: var(--usage-inset);
|
||||
color: var(--usage-muted);
|
||||
}
|
||||
|
||||
.token-usage-page .management-card--chart {
|
||||
height: clamp(390px, 50vh, 580px);
|
||||
}
|
||||
|
||||
.token-usage-page .management-empty-chart,
|
||||
.token-usage-page .management-record-empty,
|
||||
.token-usage-page .management-status-trend__empty {
|
||||
border: 1px dashed var(--usage-line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.024);
|
||||
}
|
||||
|
||||
.token-usage-page .management-model-list {
|
||||
gap: 10px;
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-model-bar {
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.045);
|
||||
border-radius: 14px;
|
||||
background: var(--usage-inset);
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.token-usage-page .management-model-bar:hover {
|
||||
border-color: var(--usage-line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.052);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.token-usage-page .management-model-bar__top strong {
|
||||
color: #eef6f2;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-model-bar__track {
|
||||
height: 7px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.token-usage-page .management-status-card dl {
|
||||
padding: 14px 18px 10px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-status-card div {
|
||||
min-height: 38px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.token-usage-page .management-status-card div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-status-card dt {
|
||||
color: var(--usage-soft);
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.token-usage-page .management-status-trend {
|
||||
padding: 14px 18px 18px;
|
||||
border-top-color: var(--usage-line);
|
||||
}
|
||||
|
||||
.token-usage-page .management-status-trend__title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--usage-muted);
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.token-usage-page .usage-trend__svg {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.token-usage-page .usage-trend__line {
|
||||
filter: drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.22));
|
||||
}
|
||||
|
||||
.token-usage-page .usage-trend__dot {
|
||||
transition: r 160ms ease;
|
||||
}
|
||||
|
||||
.token-usage-page .usage-trend__meta {
|
||||
border-top-color: var(--usage-line);
|
||||
color: var(--usage-soft);
|
||||
}
|
||||
|
||||
.token-usage-page .management-members,
|
||||
.token-usage-page .management-records {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-list {
|
||||
gap: 10px;
|
||||
padding: 12px 18px 2px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-row {
|
||||
min-height: 64px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.045);
|
||||
border-radius: 14px;
|
||||
background: var(--usage-inset);
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-row:hover {
|
||||
border-color: var(--usage-line-strong);
|
||||
background: rgba(var(--accent-rgb), 0.052);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-avatar {
|
||||
color: rgb(5, 15, 11);
|
||||
box-shadow: 0 8px 20px rgba(var(--accent-rgb), 0.16);
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-role {
|
||||
border-color: var(--usage-line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table {
|
||||
min-width: 0;
|
||||
padding: 14px 18px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__head,
|
||||
.token-usage-page .management-record-table__row {
|
||||
min-width: 880px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__head {
|
||||
min-height: 38px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
color: var(--usage-muted);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__row {
|
||||
min-height: 46px;
|
||||
background: var(--usage-inset);
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__row:hover {
|
||||
border-color: rgba(255, 255, 255, 0.065);
|
||||
background: rgba(255, 255, 255, 0.052);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__row span.is-good,
|
||||
.token-usage-page .management-record-table__row span.is-error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
min-width: 44px;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 0 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__row span.is-good {
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-table__row span.is-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-pagination {
|
||||
border-top-color: var(--usage-line);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-pagination button {
|
||||
border-color: var(--usage-line);
|
||||
border-radius: 9px;
|
||||
background: var(--usage-inset);
|
||||
color: var(--usage-muted);
|
||||
}
|
||||
|
||||
.token-usage-page .management-record-pagination button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: rgb(5, 15, 11);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.token-usage-page.management-center-page {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-shell {
|
||||
padding-inline: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 901px) and (max-width: 1180px) {
|
||||
.token-usage-page.management-center-page {
|
||||
padding-left: 82px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.token-usage-page.management-center-page {
|
||||
padding-top: 74px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-shell {
|
||||
padding: 0 16px 34px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar {
|
||||
top: 0;
|
||||
align-items: stretch;
|
||||
margin: 0 -16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 0 0 18px 18px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-toolbar__title {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-cards {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.token-usage-page .management-card--chart {
|
||||
height: auto;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-member-role,
|
||||
.token-usage-page .management-member-row > span:not(.management-member-avatar):not(.management-member-role):not(.management-member-meter),
|
||||
.token-usage-page .management-member-meter,
|
||||
.token-usage-page .management-member-row > .anticon {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.token-usage-page .management-center-toolbar button:not(.management-center-toolbar__back) {
|
||||
flex: 1 1 calc(50% - 6px);
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-center-status-pill {
|
||||
flex: 1 1 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card {
|
||||
min-height: 118px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-metric-card__value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-card__head {
|
||||
min-height: 46px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.token-usage-page .management-model-list,
|
||||
.token-usage-page .management-member-list,
|
||||
.token-usage-page .management-record-table {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,8 @@
|
||||
}
|
||||
|
||||
.member-button {
|
||||
color: var(--cyan-strong);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-button--community {
|
||||
@@ -558,12 +559,20 @@
|
||||
}
|
||||
|
||||
.web-shell__page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-color: rgba(var(--accent-rgb), 0.42) transparent;
|
||||
}
|
||||
|
||||
.keepalive-ecommerce {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Info button & popover ────────────────────── */
|
||||
.info-button {
|
||||
display: inline-grid;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const ERROR_REPORT_ENDPOINT = "/api/client-errors";
|
||||
const CLIENT_ERROR_REPORTING_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_REPORTING === "1";
|
||||
|
||||
interface ErrorReport {
|
||||
message: string;
|
||||
@@ -28,12 +27,16 @@ function getSessionId(): string | undefined {
|
||||
function flush() {
|
||||
if (reportQueue.length === 0) return;
|
||||
const batch = reportQueue.splice(0, 10);
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const url = `${baseUrl}${ERROR_REPORT_ENDPOINT}`;
|
||||
const token = localStorage.getItem("omniai:token") || sessionStorage.getItem("omniai:token") || "";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
navigator.sendBeacon?.(url, new Blob([JSON.stringify({ errors: batch })], { type: "application/json" }));
|
||||
const payload = new Blob([JSON.stringify({ errors: batch })], { type: "application/json" });
|
||||
if (navigator.sendBeacon?.(ERROR_REPORT_ENDPOINT, payload)) return;
|
||||
|
||||
void fetch(ERROR_REPORT_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ errors: batch }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
@@ -45,8 +48,6 @@ function scheduleFlush() {
|
||||
}
|
||||
|
||||
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") {
|
||||
if (!CLIENT_ERROR_REPORTING_ENABLED) return;
|
||||
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
const report: ErrorReport = {
|
||||
message: err.message,
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
export default defineConfig(() => ({
|
||||
plugins: [
|
||||
react(),
|
||||
compression({ algorithms: ["gzip", "brotliCompress"], threshold: 1024 }),
|
||||
],
|
||||
server: {
|
||||
port: 5174,
|
||||
port: 5173,
|
||||
host: "127.0.0.1",
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600",
|
||||
target: "https://omniai.net.cn",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/dashscope-api": {
|
||||
target: "https://dashscope.aliyuncs.com",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/dashscope-api/, "/compatible-mode/v1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
@@ -50,5 +42,4 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||