首页功能页更改 #9
@@ -0,0 +1,291 @@
|
||||
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.");
|
||||
+33
-10
@@ -20,6 +20,7 @@ import { reportError } from "./utils/errorReporting";
|
||||
import { initNotificationPermission } from "./utils/generationNotifier";
|
||||
import PageTransition from "./components/PageTransition";
|
||||
import ToastContainer from "./components/toast/ToastContainer";
|
||||
import { toast } from "./components/toast/toastStore";
|
||||
import { aiGenerationClient } from "./api/aiGenerationClient";
|
||||
import { keyServerClient } from "./api/keyServerClient";
|
||||
import { notificationClient } from "./api/notificationClient";
|
||||
@@ -32,7 +33,10 @@ import {
|
||||
} from "./api/serverConnection";
|
||||
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
|
||||
import { translateTaskError } from "./utils/translateTaskError";
|
||||
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
|
||||
import AppShell from "./components/AppShell";
|
||||
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
|
||||
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
|
||||
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
|
||||
const AgentPage = lazy(() => import("./features/agent/AgentPage"));
|
||||
const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
|
||||
@@ -55,7 +59,6 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat
|
||||
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
|
||||
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
|
||||
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
|
||||
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
|
||||
import {
|
||||
@@ -102,7 +105,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"ecommerce",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"settings",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
@@ -115,22 +117,29 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"communityCaseAdd",
|
||||
"report",
|
||||
"providerHealth",
|
||||
"userAgreement",
|
||||
"privacyPolicy",
|
||||
"not-found",
|
||||
]);
|
||||
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more"]);
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
|
||||
|
||||
function normalizeViewKey(rawView: string): WebViewKey {
|
||||
const normalized =
|
||||
rawView === "profile" || rawView === "auth"
|
||||
? "login"
|
||||
: rawView === "ecommerceHub"
|
||||
? "ecommerce"
|
||||
: rawView === "ecommerceHub"
|
||||
? "ecommerce"
|
||||
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
|
||||
? "userAgreement"
|
||||
: rawView === "privacy" || rawView === "privacy-policy"
|
||||
? "privacyPolicy"
|
||||
: rawView === "community-review"
|
||||
? "communityReview"
|
||||
: rawView === "community-case-add"
|
||||
? "communityCaseAdd"
|
||||
: rawView;
|
||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home";
|
||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
||||
}
|
||||
|
||||
function readViewFromHash(): WebViewKey {
|
||||
@@ -146,7 +155,8 @@ function isWorkspaceView(view: WebViewKey): boolean {
|
||||
view !== "ecommerceHub" &&
|
||||
view !== "ecommerce" &&
|
||||
view !== "scriptTokens" &&
|
||||
view !== "login"
|
||||
view !== "login" &&
|
||||
view !== "not-found"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,6 +328,11 @@ function App() {
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Recover background tasks on app start ──────────
|
||||
useEffect(() => {
|
||||
recoverAndResumeTasks();
|
||||
}, []);
|
||||
|
||||
const navItems = useMemo<WebNavItem[]>(
|
||||
() => [
|
||||
{ key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
|
||||
@@ -835,6 +850,10 @@ function App() {
|
||||
setSession(nextSession);
|
||||
await hydrateAccountData(nextSession);
|
||||
|
||||
if (nextSession.user.email && !nextSession.user.emailVerified) {
|
||||
toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证");
|
||||
}
|
||||
|
||||
const action = pendingAction;
|
||||
closeLoginPrompt();
|
||||
if (action) {
|
||||
@@ -1109,8 +1128,6 @@ function App() {
|
||||
onSelectView={handleSetView}
|
||||
/>
|
||||
);
|
||||
case "settings":
|
||||
return <SettingsPage />;
|
||||
case "imageWorkbench":
|
||||
return (
|
||||
<ImageWorkbenchPage
|
||||
@@ -1150,6 +1167,10 @@ function App() {
|
||||
return <ReportPage />;
|
||||
case "providerHealth":
|
||||
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
|
||||
case "userAgreement":
|
||||
return <CompliancePage kind="agreement" />;
|
||||
case "privacyPolicy":
|
||||
return <CompliancePage kind="privacy" />;
|
||||
case "communityReview":
|
||||
return (
|
||||
<CommunityReviewPage
|
||||
@@ -1178,7 +1199,6 @@ function App() {
|
||||
/>
|
||||
);
|
||||
case "home":
|
||||
default:
|
||||
return (
|
||||
<HomePage
|
||||
onOpenGenerate={() => handleSetView("workbench")}
|
||||
@@ -1190,6 +1210,9 @@ function App() {
|
||||
onOpenImageTool={handleOpenImageWorkbenchTool}
|
||||
/>
|
||||
);
|
||||
case "not-found":
|
||||
default:
|
||||
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
const TEXT_MODEL = "qwen-max";
|
||||
const VISION_MODEL = "qwen3.7-plus";
|
||||
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
|
||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||
|
||||
export interface AdVideoUserConfig {
|
||||
platform: string;
|
||||
@@ -110,27 +109,41 @@ interface ChatMessage {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_MS = 2000;
|
||||
const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
|
||||
const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack)
|
||||
|
||||
// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable
|
||||
function isTransientError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout");
|
||||
if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true;
|
||||
if (msg.includes("signal timed out") || msg.includes("timeout")) return true;
|
||||
if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true;
|
||||
if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures
|
||||
return false;
|
||||
}
|
||||
|
||||
async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (signal?.aborted) throw err;
|
||||
// External AbortError caused by our timeoutSignal — retryable
|
||||
if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) {
|
||||
if (attempt === MAX_RETRIES) throw err;
|
||||
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
if (attempt === MAX_RETRIES) throw err;
|
||||
if (!isTransientError(err)) throw err;
|
||||
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次");
|
||||
}
|
||||
|
||||
async function chat(
|
||||
@@ -138,33 +151,45 @@ async function chat(
|
||||
userContent: string,
|
||||
options?: { model?: string; signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
return retryOnTransient(async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: options?.model ?? TEXT_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
temperature: 0.4,
|
||||
}),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
|
||||
const payload = await res.json();
|
||||
const content: string =
|
||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||
if (!content) throw new Error("模型未返回有效内容");
|
||||
return content;
|
||||
}, options?.signal);
|
||||
const candidateModels = options?.model ? [options.model] : TEXT_MODELS;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const model of candidateModels) {
|
||||
try {
|
||||
return await retryOnTransient(async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const content: string =
|
||||
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||
if (!content) throw new Error("模型未返回有效内容");
|
||||
return content;
|
||||
}, options?.signal);
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (options?.signal?.aborted) throw lastError;
|
||||
// If user pinned a specific model, don't fall back to others
|
||||
if (options?.model) throw lastError;
|
||||
// Try next model in fallback chain
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("所有候选模型均不可用");
|
||||
}
|
||||
|
||||
async function visionChat(
|
||||
@@ -182,7 +207,8 @@ async function visionChat(
|
||||
{ role: "user", content },
|
||||
];
|
||||
|
||||
for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
|
||||
let lastError: Error | null = null;
|
||||
for (const model of VISION_MODELS) {
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
@@ -197,8 +223,8 @@ async function visionChat(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "");
|
||||
if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
||||
throw new Error(`图片理解调用失败 (${res.status})`);
|
||||
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
|
||||
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`);
|
||||
}
|
||||
const payload = await res.json();
|
||||
const result: string =
|
||||
@@ -208,12 +234,16 @@ async function visionChat(
|
||||
}, signal);
|
||||
return out;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
|
||||
if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
|
||||
throw err;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (signal?.aborted) throw lastError;
|
||||
// Continue trying next vision model on transient failures, image format errors, or upstream errors
|
||||
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
|
||||
if (lastError.message.includes("图片理解调用失败")) continue;
|
||||
if (isTransientError(lastError)) continue;
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
throw new Error("图片理解调用失败,所有模型均不可用");
|
||||
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用");
|
||||
}
|
||||
|
||||
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
|
||||
|
||||
@@ -63,6 +63,17 @@ export interface VideoGenInput {
|
||||
style?: "speech" | "sing" | "performance" | string;
|
||||
}
|
||||
|
||||
export interface VideoEditInput {
|
||||
projectId?: string;
|
||||
conversationId?: number;
|
||||
videoUrl: string;
|
||||
referenceUrls: string[];
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
ratio?: string;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface VideoSuperResolveInput {
|
||||
projectId?: string;
|
||||
conversationId?: number;
|
||||
@@ -290,6 +301,18 @@ export const aiGenerationClient = {
|
||||
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
|
||||
},
|
||||
|
||||
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/video/edit"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Video edit request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
|
||||
},
|
||||
|
||||
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
|
||||
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
|
||||
method: "POST",
|
||||
|
||||
@@ -30,9 +30,26 @@ interface EmailAuthInput {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
code?: string;
|
||||
betaCode?: string;
|
||||
}
|
||||
|
||||
interface EmailCodeInput {
|
||||
email: string;
|
||||
code: string;
|
||||
purpose?: "register" | "login";
|
||||
}
|
||||
|
||||
interface ForgotPasswordInput {
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ResetPasswordInput {
|
||||
email: string;
|
||||
code: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
interface PhoneAuthInput {
|
||||
phone: string;
|
||||
code: string;
|
||||
@@ -52,6 +69,19 @@ interface DeleteProjectOptions {
|
||||
cleanupUserData?: boolean;
|
||||
}
|
||||
|
||||
export interface RechargeOrderInput {
|
||||
planId: string;
|
||||
paymentMethod: "wechat" | "alipay" | "bank";
|
||||
}
|
||||
|
||||
export interface RechargeOrderResult {
|
||||
orderId: string;
|
||||
status: string;
|
||||
payUrl?: string | null;
|
||||
qrCodeUrl?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface WechatLoginTicket {
|
||||
configured: boolean;
|
||||
url?: string;
|
||||
@@ -624,6 +654,21 @@ function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSu
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRechargeOrder(payload: unknown): RechargeOrderResult {
|
||||
const raw = unwrapApiPayload(payload);
|
||||
if (!isRecord(raw)) {
|
||||
return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" };
|
||||
}
|
||||
|
||||
return {
|
||||
orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`),
|
||||
status: toStringValue(raw.status, "pending"),
|
||||
payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url),
|
||||
qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl),
|
||||
message: toNullableString(raw.message ?? raw.notice),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record<string, unknown> {
|
||||
const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const projectId = workflow.id.trim();
|
||||
@@ -714,6 +759,7 @@ export const keyServerClient = {
|
||||
email: input.email.trim(),
|
||||
username: input.username?.trim() || undefined,
|
||||
password: input.password,
|
||||
code: input.code?.trim() || undefined,
|
||||
betaCode: input.betaCode?.trim() || undefined,
|
||||
},
|
||||
}),
|
||||
@@ -731,6 +777,30 @@ export const keyServerClient = {
|
||||
body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined },
|
||||
});
|
||||
},
|
||||
async sendEmailCode(email: string, purpose: "login" | "register" | "reset", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }> {
|
||||
return request<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }>("/auth/email/send-code", {
|
||||
method: "POST",
|
||||
body: { email: email.trim(), purpose, betaCode: betaCode?.trim() || undefined },
|
||||
});
|
||||
},
|
||||
async verifyEmail(input: EmailCodeInput): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>("/auth/email/verify", {
|
||||
method: "POST",
|
||||
body: { email: input.email.trim(), code: input.code.trim(), purpose: input.purpose || "register" },
|
||||
});
|
||||
},
|
||||
async forgotPassword(input: ForgotPasswordInput): Promise<{ success: boolean; message?: string }> {
|
||||
return request<{ success: boolean; message?: string }>("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: { email: input.email.trim() },
|
||||
});
|
||||
},
|
||||
async resetPassword(input: ResetPasswordInput): Promise<{ success: boolean; message?: string }> {
|
||||
return request<{ success: boolean; message?: string }>("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: { email: input.email.trim(), code: input.code.trim(), newPassword: input.newPassword },
|
||||
});
|
||||
},
|
||||
async loginPhone(input: PhoneAuthInput): Promise<WebUserSession> {
|
||||
const session = normalizeLoginResult(
|
||||
await request<unknown>("/auth/login-phone", {
|
||||
@@ -855,13 +925,23 @@ export const keyServerClient = {
|
||||
return normalizeProjectContent(response, projectId);
|
||||
},
|
||||
async getUsageSummary(): Promise<WebUsageSummary> {
|
||||
return normalizeUsageSummary(await request<unknown>("/user/usage/summary"));
|
||||
const stored = readStoredSession();
|
||||
return normalizeUsageSummary(await request<unknown>("/user/usage/summary", { token: stored?.token }));
|
||||
},
|
||||
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary"));
|
||||
const stored = readStoredSession();
|
||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary", { token: stored?.token }));
|
||||
},
|
||||
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits"));
|
||||
const stored = readStoredSession();
|
||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits", { token: stored?.token }));
|
||||
},
|
||||
async createRechargeOrder(input: RechargeOrderInput): Promise<RechargeOrderResult> {
|
||||
const response = await request<unknown>("/payments/recharge-orders", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
});
|
||||
return normalizeRechargeOrder(response);
|
||||
},
|
||||
async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
||||
const stored = readStoredSession();
|
||||
@@ -929,8 +1009,8 @@ export const keyServerClient = {
|
||||
});
|
||||
},
|
||||
|
||||
async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
|
||||
const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
|
||||
async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> {
|
||||
const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
export interface ScriptEvalResult {
|
||||
totalScore: number;
|
||||
grade: string;
|
||||
@@ -8,8 +10,6 @@ export interface ScriptEvalResult {
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || "";
|
||||
const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions";
|
||||
const MODEL = "qwen3.7-max";
|
||||
|
||||
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
||||
@@ -69,16 +69,9 @@ function extractJson(text: string): unknown {
|
||||
}
|
||||
|
||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||
if (!DASHSCOPE_API_KEY) {
|
||||
throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
|
||||
}
|
||||
|
||||
const res = await fetch(DASHSCOPE_ENDPOINT, {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
|
||||
},
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
@@ -98,11 +91,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
const content: string = payload?.choices?.[0]?.message?.content
|
||||
?? payload?.result?.content
|
||||
?? payload?.content
|
||||
?? payload?.text
|
||||
?? (typeof payload === "string" ? payload : "");
|
||||
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||
|
||||
if (!content) throw new Error("模型未返回有效内容");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { keyServerClient } from "../api/keyServerClient";
|
||||
|
||||
interface ClientErrorItem {
|
||||
export interface ClientErrorItem {
|
||||
id: number;
|
||||
message: string;
|
||||
stack?: string;
|
||||
|
||||
@@ -22,6 +22,7 @@ import NotificationCenter from "./NotificationCenter";
|
||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
||||
import { AnimatedPanel } from "./AnimatedPanel";
|
||||
import AdminMonitor from "./AdminMonitor";
|
||||
import CookieConsentBanner from "./CookieConsentBanner";
|
||||
|
||||
interface AppShellProps {
|
||||
activeView: WebViewKey;
|
||||
@@ -40,6 +41,7 @@ interface AppShellProps {
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
@@ -88,7 +90,7 @@ function AppShell({
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
|
||||
const showPageScrollActions = false;
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => {
|
||||
@@ -344,8 +346,8 @@ function AppShell({
|
||||
<dd>15155073618</dd>
|
||||
</dl>
|
||||
<div className="info-popover__links">
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}>用户协议</a>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}>隐私政策</a>
|
||||
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}>隐私政策</a>
|
||||
</div>
|
||||
</AnimatedPanel>
|
||||
</div>
|
||||
@@ -356,7 +358,7 @@ function AppShell({
|
||||
onClick={() => setRechargeOpen(true)}
|
||||
>
|
||||
<WalletOutlined />
|
||||
{displayedBalanceLabel}
|
||||
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||
</button>
|
||||
<div className="profile-popover-anchor" ref={profileRef}>
|
||||
<button
|
||||
@@ -471,8 +473,9 @@ function AppShell({
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
<CookieConsentBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface NotFoundPageProps {
|
||||
onGoHome: () => void;
|
||||
}
|
||||
|
||||
function NotFoundPage({ onGoHome }: NotFoundPageProps) {
|
||||
return (
|
||||
<section className="not-found-page page-motion">
|
||||
<div className="not-found-page__content">
|
||||
<div className="not-found-page__code">404</div>
|
||||
<h1>页面未找到</h1>
|
||||
<p>您访问的页面不存在或已被移除。</p>
|
||||
<button type="button" className="not-found-page__button" onClick={onGoHome}>
|
||||
<HomeOutlined />
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundPage;
|
||||
@@ -27,7 +27,6 @@ const NAV_ORDER: string[] = [
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
"agent",
|
||||
"settings",
|
||||
"login",
|
||||
"profile",
|
||||
"report",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
|
||||
import { toast } from "../toast/toastStore";
|
||||
|
||||
type RechargeAudience = "personal" | "enterprise";
|
||||
type PaymentMethod = "wechat" | "alipay" | "bank";
|
||||
|
||||
interface MembershipPlan {
|
||||
id: string;
|
||||
@@ -107,6 +110,12 @@ const rechargeRules = [
|
||||
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
|
||||
];
|
||||
|
||||
const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [
|
||||
{ id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" },
|
||||
{ id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" },
|
||||
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
|
||||
];
|
||||
|
||||
interface RechargeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -116,14 +125,43 @@ interface RechargeModalProps {
|
||||
export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) {
|
||||
const [activeAudience, setActiveAudience] = useState<RechargeAudience>("personal");
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Record<RechargeAudience, string>>(defaultSelectedPlanIds);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [order, setOrder] = useState<RechargeOrderResult | null>(null);
|
||||
const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
|
||||
const selectedPlanId = selectedPlanIds[activeAudience];
|
||||
const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0];
|
||||
|
||||
const handlePlanSelect = (plan: MembershipPlan) => {
|
||||
setSelectedPlanIds((current) => ({
|
||||
...current,
|
||||
[plan.audience]: plan.id,
|
||||
}));
|
||||
setOrder(null);
|
||||
};
|
||||
|
||||
const handleCreateOrder = async () => {
|
||||
if (!selectedPlan || submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod });
|
||||
setOrder(nextOrder);
|
||||
if (nextOrder.payUrl) {
|
||||
window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
toast.success("充值订单已创建");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。";
|
||||
toast.error(message);
|
||||
setOrder({
|
||||
orderId: `support-${Date.now()}`,
|
||||
status: "manual-review",
|
||||
message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
@@ -224,6 +262,44 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr
|
||||
))}
|
||||
</ol>
|
||||
</footer>
|
||||
|
||||
<section className="recharge-modal__checkout" aria-label="支付方式">
|
||||
<div>
|
||||
<span className="recharge-modal__checkout-eyebrow">支付确认</span>
|
||||
<h3>{selectedPlan.name} · {selectedPlan.period}</h3>
|
||||
<p>{selectedPlan.price},{selectedPlan.grant}</p>
|
||||
</div>
|
||||
<div className="recharge-modal__payment-methods" role="radiogroup" aria-label="选择支付方式">
|
||||
{paymentMethods.map((method) => (
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={paymentMethod === method.id}
|
||||
className={paymentMethod === method.id ? "is-active" : ""}
|
||||
onClick={() => {
|
||||
setPaymentMethod(method.id);
|
||||
setOrder(null);
|
||||
}}
|
||||
>
|
||||
<strong>{method.label}</strong>
|
||||
<span>{method.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="recharge-modal__pay" onClick={() => void handleCreateOrder()} disabled={submitting}>
|
||||
{submitting ? "创建订单中..." : "立即充值"}
|
||||
</button>
|
||||
{order ? (
|
||||
<div className="recharge-modal__order" role="status">
|
||||
<strong>订单号:{order.orderId}</strong>
|
||||
<span>状态:{order.status}</span>
|
||||
{order.qrCodeUrl ? <img src={order.qrCodeUrl} alt="支付二维码" /> : null}
|
||||
{order.payUrl ? <a href={order.payUrl} target="_blank" rel="noreferrer">打开支付链接</a> : null}
|
||||
<p>{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, asset });
|
||||
}, []);
|
||||
|
||||
const handleDeleteAsset = useCallback(async () => {
|
||||
if (!contextMenu) return;
|
||||
const { asset } = contextMenu;
|
||||
const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => {
|
||||
const target = asset || contextMenu?.asset;
|
||||
if (!target) return;
|
||||
setContextMenu(null);
|
||||
try {
|
||||
await assetClient.delete(asset.id);
|
||||
setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
|
||||
setServerNotice(`已删除 ${asset.name}`);
|
||||
await assetClient.delete(target.id);
|
||||
setServerAssets((prev) => prev.filter((a) => a.id !== target.id));
|
||||
setServerNotice(`已删除 ${target.name}`);
|
||||
} catch (err) {
|
||||
setServerNotice(err instanceof Error ? err.message : "删除失败");
|
||||
}
|
||||
@@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
{visibleAssets.length ? (
|
||||
<div className="asset-grid asset-grid--desktop motion-stagger">
|
||||
{visibleAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
className="asset-card asset-card--desktop"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
aria-label={`预览素材 ${asset.name}`}
|
||||
>
|
||||
<div className={`asset-card__thumb ${asset.thumbClass}`}>
|
||||
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
|
||||
</div>
|
||||
<div className="asset-card__body">
|
||||
<div className="asset-card__head">
|
||||
<strong>{asset.name}</strong>
|
||||
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
|
||||
{statusLabel[asset.status]}
|
||||
</span>
|
||||
<div key={asset.id} className="asset-card-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="asset-card asset-card--desktop"
|
||||
onClick={() => setPreviewAsset(asset)}
|
||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||
aria-label={`预览素材 ${asset.name}`}
|
||||
>
|
||||
<div className={`asset-card__thumb ${asset.thumbClass}`}>
|
||||
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null}
|
||||
</div>
|
||||
<p className="asset-card__desc">{asset.description}</p>
|
||||
<div className="asset-card__tags">
|
||||
{asset.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
<div className="asset-card__body">
|
||||
<div className="asset-card__head">
|
||||
<strong>{asset.name}</strong>
|
||||
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}>
|
||||
{statusLabel[asset.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="asset-card__desc">{asset.description}</p>
|
||||
<div className="asset-card__tags">
|
||||
{asset.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="asset-card__delete"
|
||||
title="删除素材"
|
||||
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
|
||||
aria-label={`删除 ${asset.name}`}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
|
||||
@@ -3717,6 +3717,9 @@ function CanvasPage({
|
||||
<ReactFlow
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
minZoom={0.3}
|
||||
maxZoom={1.6}
|
||||
panOnDrag={false}
|
||||
@@ -5531,6 +5534,11 @@ 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
|
||||
@@ -5542,8 +5550,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addTextNode(undefined, pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -5559,8 +5565,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addImageNode("", "图片节点", pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
@@ -5576,8 +5580,6 @@ function CanvasPage({
|
||||
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
|
||||
addVideoNode(pos);
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
setConnectionDropMenu(null);
|
||||
}}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PLAN_STEPS_DISPLAY,
|
||||
type EcommerceVideoStage,
|
||||
type EcommerceVideoSceneTask,
|
||||
type EcommerceVideoPlanProgress,
|
||||
type EcommerceVideoPlanResult,
|
||||
type PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
@@ -22,6 +23,7 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
|
||||
import { ServerRequestError } from "../../api/serverConnection";
|
||||
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
|
||||
import { useAppStore } from "../../stores";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
import {
|
||||
saveEcommerceVideoState,
|
||||
loadEcommerceVideoState,
|
||||
@@ -44,10 +46,51 @@ const ALL_STEPS: PlanStep[] = [
|
||||
"creative", "storyboard", "prompts", "compliance",
|
||||
];
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function buildInputFingerprint(input: {
|
||||
productImageDataUrls: string[];
|
||||
requirement: string;
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
}): string {
|
||||
const imageCount = input.productImageDataUrls.length;
|
||||
return hashString([
|
||||
String(imageCount),
|
||||
input.requirement.trim(),
|
||||
input.platform,
|
||||
input.aspectRatio,
|
||||
input.durationSeconds,
|
||||
input.resolution,
|
||||
].join("::"));
|
||||
}
|
||||
|
||||
function mapResolutionToQuality(res: string): "720P" | "1080P" {
|
||||
return res.includes("720") ? "720P" : "1080P";
|
||||
}
|
||||
|
||||
function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress): boolean {
|
||||
switch (step) {
|
||||
case "upload": return Boolean(p.imageUrls?.length);
|
||||
case "analyze": return p.imageDescription !== undefined;
|
||||
case "summary": return Boolean(p.summary);
|
||||
case "selling": return Boolean(p.selling);
|
||||
case "creative": return Boolean(p.creatives?.length);
|
||||
case "storyboard": return Boolean(p.storyboard);
|
||||
case "prompts": return Boolean(p.videoPrompts);
|
||||
case "compliance": return Boolean(p.compliance);
|
||||
}
|
||||
}
|
||||
|
||||
export default function EcommerceVideoWorkspace({
|
||||
isAuthenticated,
|
||||
productImageDataUrls,
|
||||
@@ -60,38 +103,67 @@ export default function EcommerceVideoWorkspace({
|
||||
}: EcommerceVideoWorkspaceProps) {
|
||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
|
||||
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
|
||||
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
|
||||
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
|
||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
const keepaliveRestoredRef = useRef(false);
|
||||
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||
const keepalivePollingStartedRef = useRef(false);
|
||||
const generation = useGenerationTasks({ sourceView: "ecommerce" });
|
||||
const sceneStoreIdMap = useRef<Map<number, string>>(new Map());
|
||||
const inputFingerprint = useMemo(
|
||||
() => buildInputFingerprint({ productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution }),
|
||||
[productImageDataUrls, requirement, platform, aspectRatio, durationSeconds, resolution],
|
||||
);
|
||||
|
||||
// ── Keep-alive: restore saved state on mount ─────────────
|
||||
useEffect(() => {
|
||||
if (keepaliveRestoredRef.current) return;
|
||||
keepaliveRestoredRef.current = true;
|
||||
const saved = loadEcommerceVideoState();
|
||||
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||||
keepaliveRestoredFingerprintRef.current = inputFingerprint;
|
||||
const saved = loadEcommerceVideoState(inputFingerprint);
|
||||
if (!saved) return;
|
||||
if (saved.stage === "idle" || saved.stage === "cancelled") return;
|
||||
// Restore completed / in-progress states — results persist across page switches
|
||||
setStage(saved.stage);
|
||||
setCompletedSteps(saved.completedSteps || []);
|
||||
setPlanResult(saved.planResult);
|
||||
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
|
||||
setScenes(saved.scenes || []);
|
||||
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
|
||||
}, []);
|
||||
}, [inputFingerprint]);
|
||||
|
||||
// ── Keep-alive: save state on changes ───────────────────
|
||||
useEffect(() => {
|
||||
if (stage === "idle" || stage === "cancelled") return;
|
||||
saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
|
||||
}, [stage, completedSteps, planResult, scenes, sourceImageUrls]);
|
||||
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);
|
||||
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]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
useEffect(() => {
|
||||
@@ -253,40 +325,89 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
// ── Phase 1: Planning ──────────────────────────────────────
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
|
||||
abortControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
setStage("planning"); setError(null);
|
||||
setCompletedSteps([]); setCurrentStep(null);
|
||||
setPlanResult(null); setScenes([]); setSourceImageUrls([]);
|
||||
setStage("planning"); setError(null); setFailedStep(null);
|
||||
if (!resume) {
|
||||
setCompletedSteps([]); setPlanResult(null); setScenes([]); setSourceImageUrls([]); setPlanProgress(null);
|
||||
}
|
||||
setCurrentStep(null);
|
||||
// Mutable snapshot — async handlers must persist to localStorage directly since the component may unmount
|
||||
let livePlanProgress: EcommerceVideoPlanProgress = resume ? { ...resume } : {};
|
||||
let liveCompletedSteps: PlanStep[] = resume
|
||||
? ALL_STEPS.filter((s) => stepCompletedFromProgress(s, resume))
|
||||
: [];
|
||||
const persist = (stageNow: EcommerceVideoStage) => {
|
||||
saveEcommerceVideoState({
|
||||
inputFingerprint,
|
||||
stage: stageNow,
|
||||
completedSteps: liveCompletedSteps,
|
||||
planResult: null,
|
||||
planProgress: livePlanProgress,
|
||||
scenes: [],
|
||||
sourceImageUrls: livePlanProgress.imageUrls || [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await runVideoPlan(
|
||||
productImageDataUrls, requirement, buildConfig(),
|
||||
{
|
||||
onStepStart: (step) => setCurrentStep(step),
|
||||
onStepDone: (step) => setCompletedSteps((prev) => [...prev, step]),
|
||||
onImagesUploaded: (urls) => { setSourceImageUrls(urls); saveEcommerceVideoState({ stage: "planning", completedSteps: ["upload"], planResult: null, scenes: [], sourceImageUrls: urls }); },
|
||||
onStepDone: (step) => {
|
||||
liveCompletedSteps = [...liveCompletedSteps, step];
|
||||
setCompletedSteps((prev) => [...prev, step]);
|
||||
},
|
||||
onImagesUploaded: (urls) => {
|
||||
setSourceImageUrls(urls);
|
||||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||||
persist("planning");
|
||||
},
|
||||
onUploadRejected: (messages) => {
|
||||
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
||||
},
|
||||
onPartialProgress: (progress) => {
|
||||
livePlanProgress = progress;
|
||||
setPlanProgress(progress);
|
||||
persist("planning");
|
||||
},
|
||||
resumeFrom: resume || undefined,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const builtScenes = buildSceneTasks(result);
|
||||
setPlanResult(result);
|
||||
setPlanProgress(null);
|
||||
setScenes(builtScenes);
|
||||
setStage("planned");
|
||||
// Persist immediately — component may be unmounted by the time React re-renders
|
||||
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
setError(err instanceof Error ? err.message : "策划失败");
|
||||
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : "策划失败";
|
||||
setError(message);
|
||||
// Mark the step that was in-progress as failed so user can resume
|
||||
setFailedStep((prev) => prev || currentStep);
|
||||
setStage("idle");
|
||||
// Persist partial progress so the user can resume after a page switch
|
||||
persist("idle");
|
||||
} finally { setCurrentStep(null); }
|
||||
};
|
||||
|
||||
const handlePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!productImageDataUrls.length && !requirement.trim()) {
|
||||
setError("请先上传产品图片或填写商品说明"); return;
|
||||
}
|
||||
await runPlanFlow(null);
|
||||
};
|
||||
|
||||
const handleResumePlan = async () => {
|
||||
if (!isAuthenticated) { onRequestLogin?.(); return; }
|
||||
if (!planProgress) { void handlePlan(); return; }
|
||||
await runPlanFlow(planProgress);
|
||||
};
|
||||
|
||||
// ── Phase 2: Image generation per scene ──────────────────────
|
||||
|
||||
const handleGenerateImages = async () => {
|
||||
@@ -300,19 +421,34 @@ export default function EcommerceVideoWorkspace({
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
for (const scene of currentScenes) {
|
||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||
if (!scenesToProcess.length) { setStage("imaged"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||
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 },
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
|
||||
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
|
||||
onSceneImageCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneImageFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
@@ -324,15 +460,14 @@ export default function EcommerceVideoWorkspace({
|
||||
const allHaveImages = currentScenes.every((s) => s.imageUrl);
|
||||
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
// ── Phase 3: Video rendering from generated images ──────────
|
||||
|
||||
const handleRenderVideos = async () => {
|
||||
if (!scenes.length) return;
|
||||
const firstImage = scenes[0]?.imageUrl;
|
||||
if (!firstImage) { setError("请先生成分镜图片"); return; }
|
||||
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; }
|
||||
setStage("rendering"); setError(null);
|
||||
renderAbortRef.current = { current: false };
|
||||
const quality = mapResolutionToQuality(resolution);
|
||||
@@ -340,20 +475,35 @@ export default function EcommerceVideoWorkspace({
|
||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||
currentScenes = next;
|
||||
setScenes(next);
|
||||
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||
};
|
||||
for (const scene of currentScenes) {
|
||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||
if (!scenesToProcess.length) { setStage(currentScenes.every((s) => s.status === "completed") ? "completed" : "partial_failed"); return; }
|
||||
for (const scene of scenesToProcess) {
|
||||
if (renderAbortRef.current.current) break;
|
||||
if (!scene.imageUrl) continue;
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
|
||||
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 },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
|
||||
sceneStoreIdMap.current.set(id, storeId);
|
||||
},
|
||||
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
||||
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
||||
onSceneCompleted: (id, url) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markCompleted(sid, url);
|
||||
},
|
||||
onSceneFailed: (id, err2) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
|
||||
const sid = sceneStoreIdMap.current.get(id);
|
||||
if (sid) generation.markFailed(sid, err2);
|
||||
},
|
||||
},
|
||||
renderAbortRef.current,
|
||||
);
|
||||
@@ -369,7 +519,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
|
||||
setScenes(currentScenes);
|
||||
setStage(finalStage);
|
||||
saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||
};
|
||||
|
||||
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
||||
@@ -424,26 +574,32 @@ export default function EcommerceVideoWorkspace({
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{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"
|
||||
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
|
||||
<ReloadOutlined /> 继续
|
||||
</button>
|
||||
) : null}
|
||||
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action"
|
||||
onClick={() => void handlePlan()} title="一键策划">
|
||||
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planned" ? (
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title="生成图片">
|
||||
<SendOutlined />
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />}
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "imaged" ? (
|
||||
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleRenderVideos()} title="生成视频">
|
||||
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planning" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 策划中</span>
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</span>
|
||||
) : null}
|
||||
{stage === "imaging" ? (
|
||||
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> 生成图片中</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
EcommerceVideoStage,
|
||||
EcommerceVideoSceneTask,
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
@@ -8,18 +9,22 @@ import type {
|
||||
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
|
||||
|
||||
interface EcommerceVideoKeepalive {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls: string[];
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export function saveEcommerceVideoState(state: {
|
||||
inputFingerprint: string;
|
||||
stage: EcommerceVideoStage;
|
||||
completedSteps: PlanStep[];
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
planProgress?: EcommerceVideoPlanProgress | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
sourceImageUrls?: string[];
|
||||
}): void {
|
||||
@@ -35,7 +40,7 @@ export function saveEcommerceVideoState(state: {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
||||
export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVideoKeepalive | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEEPALIVE_KEY);
|
||||
if (!raw) return null;
|
||||
@@ -45,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
||||
clearEcommerceVideoState();
|
||||
return null;
|
||||
}
|
||||
if (parsed.inputFingerprint !== inputFingerprint) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
|
||||
import type {
|
||||
EcommerceVideoPlanProgress,
|
||||
EcommerceVideoPlanResult,
|
||||
EcommerceVideoSceneTask,
|
||||
PlanStep,
|
||||
@@ -21,66 +23,129 @@ export interface PlanCallbacks {
|
||||
onStepStart: (step: PlanStep) => void;
|
||||
onStepDone: (step: PlanStep) => void;
|
||||
onImagesUploaded?: (urls: string[]) => void;
|
||||
onUploadRejected?: (messages: string[]) => void;
|
||||
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Partial state from a previous run; steps with existing data are skipped. */
|
||||
resumeFrom?: EcommerceVideoPlanProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full ad video planning pipeline.
|
||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||
*/
|
||||
export async function runVideoPlan(
|
||||
imageDataUrls: string[],
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
): Promise<EcommerceVideoPlanResult> {
|
||||
const { onStepStart, onStepDone, signal } = callbacks;
|
||||
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks;
|
||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
for (const srcUrl of imageDataUrls) {
|
||||
try {
|
||||
const resp = await fetch(srcUrl);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
|
||||
imageUrls.push(result.url);
|
||||
} catch {
|
||||
// skip images that fail to upload
|
||||
// ── Step: upload ──────────────────────────────────────
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const srcUrl of imageDataUrls) {
|
||||
try {
|
||||
const resp = await fetch(srcUrl);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
imageUrls.push(result.url);
|
||||
} catch (err) {
|
||||
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
||||
}
|
||||
}
|
||||
if (rejected.length) {
|
||||
progress.uploadWarnings = rejected;
|
||||
callbacks.onUploadRejected?.(rejected);
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
progress.imageUrls = imageUrls;
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
emit();
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
|
||||
onStepStart("analyze");
|
||||
const imageDesc = await analyzeProductImages(imageUrls, signal);
|
||||
onStepDone("analyze");
|
||||
// ── Step: analyze ─────────────────────────────────────
|
||||
if (progress.imageDescription === undefined) {
|
||||
onStepStart("analyze");
|
||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||
onStepDone("analyze");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("summary");
|
||||
const summary = await buildProductSummary(imageDesc, manualText, signal);
|
||||
onStepDone("summary");
|
||||
// ── Step: summary ─────────────────────────────────────
|
||||
if (!progress.summary) {
|
||||
onStepStart("summary");
|
||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||
onStepDone("summary");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("selling");
|
||||
const selling = await extractSellingPoints(summary, signal);
|
||||
onStepDone("selling");
|
||||
// ── Step: selling ─────────────────────────────────────
|
||||
if (!progress.selling) {
|
||||
onStepStart("selling");
|
||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||
onStepDone("selling");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("creative");
|
||||
const creatives = await generateCreativeOptions(selling, config, signal);
|
||||
if (!creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
onStepDone("creative");
|
||||
// ── Step: creative ────────────────────────────────────
|
||||
if (!progress.creatives?.length) {
|
||||
onStepStart("creative");
|
||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
onStepDone("creative");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("storyboard");
|
||||
const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
// ── Step: storyboard ──────────────────────────────────
|
||||
if (!progress.storyboard) {
|
||||
onStepStart("storyboard");
|
||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||
onStepDone("storyboard");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("prompts");
|
||||
const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
|
||||
onStepDone("prompts");
|
||||
// ── Step: prompts ─────────────────────────────────────
|
||||
if (!progress.videoPrompts) {
|
||||
onStepStart("prompts");
|
||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||
onStepDone("prompts");
|
||||
emit();
|
||||
}
|
||||
|
||||
onStepStart("compliance");
|
||||
const compliance = await checkCompliance(summary, selling, storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
// ── Step: compliance ──────────────────────────────────
|
||||
if (!progress.compliance) {
|
||||
onStepStart("compliance");
|
||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||
onStepDone("compliance");
|
||||
emit();
|
||||
}
|
||||
|
||||
return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
|
||||
return {
|
||||
imageUrls: progress.imageUrls!,
|
||||
imageDescription: progress.imageDescription,
|
||||
summary: progress.summary!,
|
||||
selling: progress.selling!,
|
||||
creatives: progress.creatives!,
|
||||
storyboard: progress.storyboard!,
|
||||
videoPrompts: progress.videoPrompts!,
|
||||
compliance: progress.compliance!,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RenderSceneImageInput {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface EcommerceVideoSceneTask {
|
||||
|
||||
export interface EcommerceVideoPlanResult {
|
||||
imageUrls: string[];
|
||||
imageDescription?: string;
|
||||
summary: ProductSummary;
|
||||
selling: SellingPointResult;
|
||||
creatives: CreativeOption[];
|
||||
@@ -44,6 +45,19 @@ export interface EcommerceVideoPlanResult {
|
||||
compliance: ComplianceCheck;
|
||||
}
|
||||
|
||||
/** Partial plan state — used as resume input when an earlier run failed mid-flow. */
|
||||
export interface EcommerceVideoPlanProgress {
|
||||
imageUrls?: string[];
|
||||
imageDescription?: string;
|
||||
uploadWarnings?: string[];
|
||||
summary?: ProductSummary;
|
||||
selling?: SellingPointResult;
|
||||
creatives?: CreativeOption[];
|
||||
storyboard?: Storyboard;
|
||||
videoPrompts?: VideoPrompt[];
|
||||
compliance?: ComplianceCheck;
|
||||
}
|
||||
|
||||
export interface EcommerceVideoDelivery {
|
||||
planResult: EcommerceVideoPlanResult | null;
|
||||
scenes: EcommerceVideoSceneTask[];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
CameraOutlined,
|
||||
CheckOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
MobileOutlined,
|
||||
@@ -134,6 +137,48 @@ function mapAssetToSavedItem(asset: Awaited<ReturnType<typeof assetClient.list>>
|
||||
};
|
||||
}
|
||||
|
||||
function formatProfileDate(value: string | null | undefined): string {
|
||||
if (!value) return "刚刚";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatTaskType(type: WebGenerationPreviewTask["type"]): string {
|
||||
const labels: Record<WebGenerationPreviewTask["type"], string> = {
|
||||
image: "图像",
|
||||
video: "视频",
|
||||
agent: "智能体",
|
||||
"digital-human": "数字人",
|
||||
"character-mix": "角色融合",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function formatTaskStatus(status: WebGenerationPreviewTask["status"]): string {
|
||||
const labels: Record<WebGenerationPreviewTask["status"], string> = {
|
||||
queued: "排队中",
|
||||
running: "生成中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
function formatAssetStatus(status: string | undefined): string {
|
||||
const normalized = String(status || "").toLowerCase();
|
||||
if (normalized === "completed" || normalized === "ready" || normalized === "success") return "可用";
|
||||
if (normalized === "running" || normalized === "processing") return "处理中";
|
||||
if (normalized === "failed" || normalized === "error") return "失败";
|
||||
return status || "资产";
|
||||
}
|
||||
|
||||
function ProfilePage({
|
||||
session,
|
||||
usage,
|
||||
@@ -169,6 +214,14 @@ function ProfilePage({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||
const [isSendingSms, setIsSendingSms] = useState(false);
|
||||
const [emailCode, setEmailCode] = useState("");
|
||||
const [emailCooldown, setEmailCooldown] = useState(0);
|
||||
const [isSendingEmail, setIsSendingEmail] = useState(false);
|
||||
const [showForgotPassword, setShowForgotPassword] = useState(false);
|
||||
const [forgotStep, setForgotStep] = useState<"email" | "code" | "newPassword">("email");
|
||||
const [forgotEmail, setForgotEmail] = useState("");
|
||||
const [forgotCode, setForgotCode] = useState("");
|
||||
const [forgotPassword, setForgotPassword] = useState("");
|
||||
|
||||
const [activePanel, setActivePanel] = useState<ProfilePanel>("works");
|
||||
const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
|
||||
@@ -179,6 +232,9 @@ function ProfilePage({
|
||||
const [profileNotice, setProfileNotice] = useState<string | null>(null);
|
||||
const [localAvatarUrl, setLocalAvatarUrl] = useState(() => session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
|
||||
const [profileBio, setProfileBio] = useState(() => session?.user.bio || readLocalProfileValue(userId, "bio"));
|
||||
const [isBioEditing, setIsBioEditing] = useState(false);
|
||||
const [bioEditBackup, setBioEditBackup] = useState("");
|
||||
const [bioStatusNotice, setBioStatusNotice] = useState<string | null>(null);
|
||||
const [bannerUrl, setBannerUrl] = useState(() => session?.user.backgroundUrl || readLocalProfileValue(userId, "background"));
|
||||
|
||||
const completedTasks = tasks.filter((task) => task.status === "completed");
|
||||
@@ -187,6 +243,9 @@ function ProfilePage({
|
||||
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
|
||||
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
|
||||
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
|
||||
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
||||
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
|
||||
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAvatarUrl(session?.user.avatarUrl || readLocalProfileValue(userId, "avatar"));
|
||||
@@ -245,6 +304,70 @@ function ProfilePage({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [smsCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setEmailCooldown((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [emailCooldown]);
|
||||
|
||||
const handleSendEmailCode = async (purpose: "register" | "login" | "reset" = "register") => {
|
||||
const targetEmail = purpose === "reset" ? forgotEmail : email;
|
||||
if (emailCooldown > 0 || !targetEmail.trim() || isSendingEmail) return;
|
||||
if (purpose === "register" && !betaCode.trim()) {
|
||||
setNotice("请输入企业邀请码 / 内测码后再获取验证码");
|
||||
return;
|
||||
}
|
||||
setIsSendingEmail(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await keyServerClient.sendEmailCode(targetEmail, purpose, betaCode);
|
||||
setEmailCooldown(result.cooldownSeconds || 60);
|
||||
if (result.devCode) {
|
||||
setNotice(`验证码已发送(开发模式: ${result.devCode})`);
|
||||
} else {
|
||||
setNotice("验证码已发送,请查收邮件");
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "验证码发送失败");
|
||||
} finally {
|
||||
setIsSendingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
if (forgotStep === "email") {
|
||||
if (!forgotEmail.trim()) { setNotice("请输入邮箱"); return; }
|
||||
try {
|
||||
await keyServerClient.forgotPassword({ email: forgotEmail });
|
||||
setForgotStep("code");
|
||||
setNotice("重置验证码已发送到您的邮箱");
|
||||
await handleSendEmailCode("reset");
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "发送失败");
|
||||
}
|
||||
} else if (forgotStep === "code") {
|
||||
if (!forgotCode.trim()) { setNotice("请输入验证码"); return; }
|
||||
setForgotStep("newPassword");
|
||||
setNotice(null);
|
||||
} else {
|
||||
if (forgotPassword.length < 6) { setNotice("密码至少 6 位"); return; }
|
||||
try {
|
||||
const result = await keyServerClient.resetPassword({ email: forgotEmail, code: forgotCode, newPassword: forgotPassword });
|
||||
setNotice(result.message || "密码重置成功,请重新登录");
|
||||
setShowForgotPassword(false);
|
||||
setForgotStep("email");
|
||||
setForgotEmail("");
|
||||
setForgotCode("");
|
||||
setForgotPassword("");
|
||||
setMode("login");
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "重置失败");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSms = async () => {
|
||||
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
|
||||
if (mode === "register" && !betaCode.trim()) {
|
||||
@@ -289,6 +412,10 @@ function ProfilePage({
|
||||
if (!value.trim()) return "请输入验证码";
|
||||
if (value.length !== 6) return "验证码为 6 位数字";
|
||||
return "";
|
||||
case "emailCode":
|
||||
if (!value.trim()) return "请输入邮箱验证码";
|
||||
if (value.length !== 6) return "验证码为 6 位数字";
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -328,6 +455,10 @@ function ProfilePage({
|
||||
if (emailErr) errors.email = emailErr;
|
||||
const pwErr = validateField("password", password);
|
||||
if (pwErr) errors.password = pwErr;
|
||||
if (mode === "register") {
|
||||
const codeErr = validateField("emailCode", emailCode);
|
||||
if (codeErr) errors.emailCode = codeErr;
|
||||
}
|
||||
} else {
|
||||
const userErr = validateField("username", username);
|
||||
if (userErr) errors.username = userErr;
|
||||
@@ -354,7 +485,7 @@ function ProfilePage({
|
||||
const nextSession =
|
||||
mode === "login"
|
||||
? await keyServerClient.loginEmail({ email, password })
|
||||
: await keyServerClient.registerEmail({ email, password, username: username.trim() || undefined, betaCode });
|
||||
: await keyServerClient.registerEmail({ email, password, code: emailCode, username: username.trim() || undefined, betaCode });
|
||||
await onAuthComplete?.(nextSession);
|
||||
} else if (mode === "login") {
|
||||
await onLogin(username.trim(), password);
|
||||
@@ -445,8 +576,29 @@ function ProfilePage({
|
||||
void syncProfilePatch({ bio: nextBio || null });
|
||||
};
|
||||
|
||||
const startBioEdit = () => {
|
||||
setBioEditBackup(profileBio);
|
||||
setBioStatusNotice(null);
|
||||
setIsBioEditing(true);
|
||||
};
|
||||
|
||||
const confirmBioEdit = () => {
|
||||
handleBioBlur();
|
||||
setIsBioEditing(false);
|
||||
setBioStatusNotice("个性签名已保存");
|
||||
};
|
||||
|
||||
const cancelBioEdit = () => {
|
||||
setProfileBio(bioEditBackup);
|
||||
setIsBioEditing(false);
|
||||
setBioStatusNotice(null);
|
||||
};
|
||||
|
||||
const renderEmptyState = (text: string, actionLabel: string, action: () => void) => (
|
||||
<div className="profile-page__empty-state">
|
||||
<span className="profile-page__empty-mark" aria-hidden="true">
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<p className="profile-page__empty-text">{text}</p>
|
||||
<button type="button" className="profile-page__empty-btn" onClick={action}>
|
||||
<PlusOutlined />
|
||||
@@ -464,12 +616,12 @@ function ProfilePage({
|
||||
<article key={task.id} className="profile-page__list-card">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{task.title}</strong>
|
||||
<span>{task.type}</span>
|
||||
<span>{formatTaskType(task.type)}</span>
|
||||
</div>
|
||||
<p>{task.prompt}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{task.status}</span>
|
||||
<span>{task.createdAt}</span>
|
||||
<span>{formatTaskStatus(task.status)}</span>
|
||||
<span>{formatProfileDate(task.createdAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -487,7 +639,7 @@ function ProfilePage({
|
||||
<article key={project.id} className="profile-page__list-card">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.updatedAt}</span>
|
||||
<span>{formatProfileDate(project.updatedAt)}</span>
|
||||
{onDeleteProject ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -519,12 +671,12 @@ function ProfilePage({
|
||||
<article key={asset.id} className="profile-page__list-card">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{asset.name}</strong>
|
||||
<span>{asset.status}</span>
|
||||
<span>{formatAssetStatus(asset.status)}</span>
|
||||
</div>
|
||||
<p>{asset.description}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{asset.type}</span>
|
||||
<span>{asset.updatedAt}</span>
|
||||
<span>{formatProfileDate(asset.updatedAt)}</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -587,15 +739,39 @@ function ProfilePage({
|
||||
</span>
|
||||
</div>
|
||||
<strong className="profile-page__username">{displayName}</strong>
|
||||
<textarea
|
||||
className="profile-page__bio"
|
||||
value={profileBio}
|
||||
onChange={(event) => setProfileBio(event.target.value)}
|
||||
onBlur={handleBioBlur}
|
||||
placeholder={displayedBio}
|
||||
rows={2}
|
||||
maxLength={80}
|
||||
/>
|
||||
{isBioEditing ? (
|
||||
<div className="profile-page__bio-editor">
|
||||
<textarea
|
||||
className="profile-page__bio"
|
||||
value={profileBio}
|
||||
onChange={(event) => setProfileBio(event.target.value)}
|
||||
placeholder="填写一句个人签名"
|
||||
rows={2}
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="profile-page__bio-actions">
|
||||
<button type="button" className="profile-page__bio-action profile-page__bio-action--save" onClick={confirmBioEdit}>
|
||||
<CheckOutlined />
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="profile-page__bio-action" onClick={cancelBioEdit}>
|
||||
<CloseOutlined />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`profile-page__bio-display${profileBio.trim() ? "" : " is-empty"}`}
|
||||
onClick={startBioEdit}
|
||||
>
|
||||
<span>{displayedBio}</span>
|
||||
<EditOutlined className="profile-page__bio-edit-icon" />
|
||||
</button>
|
||||
)}
|
||||
{bioStatusNotice ? <span className="profile-page__bio-status">{bioStatusNotice}</span> : null}
|
||||
{profileNotice ? <span className="profile-page__sync-notice">{profileNotice}</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -614,18 +790,21 @@ function ProfilePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="profile-page__share-btn">
|
||||
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
|
||||
<ShareAltOutlined />
|
||||
{packageLabel}
|
||||
</button>
|
||||
|
||||
<button type="button" className="profile-page__share-btn" onClick={onOpenWorkbench}>
|
||||
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
|
||||
<PlusOutlined />
|
||||
进入工作台
|
||||
</button>
|
||||
<button type="button" className="profile-page__share-btn" onClick={onOpenCommunity}>
|
||||
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
|
||||
<ShareAltOutlined />
|
||||
打开社区
|
||||
</button>
|
||||
<button type="button" className="profile-page__share-btn" onClick={onLogout}>
|
||||
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
|
||||
<LockOutlined />
|
||||
退出登录
|
||||
</button>
|
||||
</aside>
|
||||
@@ -681,13 +860,25 @@ function ProfilePage({
|
||||
<div className="profile-page__upload-card profile-page__upload-card--meta">
|
||||
{accountPanel === "credits" ? (
|
||||
<>
|
||||
<span>当前账号:{displayName}</span>
|
||||
<span>积分剩余:{(usage.balanceCents / 100).toFixed(2)}</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>当前账号</small>
|
||||
<strong>{displayName}</strong>
|
||||
</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>积分剩余</small>
|
||||
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>任务总数:{tasks.length}</span>
|
||||
<span>已完成:{completedTasks.length}</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>任务总数</small>
|
||||
<strong>{tasks.length}</strong>
|
||||
</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>已完成</small>
|
||||
<strong>{completedTasks.length}</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -706,12 +897,30 @@ function ProfilePage({
|
||||
<source src={AUTH_SHOWCASE_VIDEO_URL} type="video/mp4" />
|
||||
</video>
|
||||
<div className="auth-page__video-overlay">
|
||||
<h1 className="auth-page__brand">OmniAI</h1>
|
||||
<p className="auth-page__tagline">一句话,从创意到成片</p>
|
||||
<div className="auth-page__features">
|
||||
<span>AI 视频生成</span>
|
||||
<span>AI 图片创作</span>
|
||||
<span>AI 电商素材</span>
|
||||
<div className="auth-page__showcase-content">
|
||||
<div className="auth-page__brand-row">
|
||||
<h1 className="auth-page__brand">OmniAI</h1>
|
||||
</div>
|
||||
<p className="auth-page__tagline">一句话,从创意到成片</p>
|
||||
<div className="auth-page__features">
|
||||
<span>AI 视频生成</span>
|
||||
<span>AI 图片创作</span>
|
||||
<span>AI 电商素材</span>
|
||||
</div>
|
||||
<div className="auth-page__showcase-stats" aria-label="平台能力">
|
||||
<span>
|
||||
<strong>Studio</strong>
|
||||
创作工作台
|
||||
</span>
|
||||
<span>
|
||||
<strong>Assets</strong>
|
||||
资产沉淀
|
||||
</span>
|
||||
<span>
|
||||
<strong>Team</strong>
|
||||
团队协作
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -762,7 +971,8 @@ function ProfilePage({
|
||||
<MailOutlined /> 邮箱
|
||||
</button>
|
||||
<button type="button" className={authTab === "phone" ? "is-active" : ""} onClick={() => { setAuthTab("phone"); setFieldErrors({}); }}>
|
||||
<MobileOutlined /> 手机验证码
|
||||
<MobileOutlined />
|
||||
<span className="auth-page__tab-label-short">手机</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -790,7 +1000,31 @@ function ProfilePage({
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{authTab === "password" ? (
|
||||
{showForgotPassword ? (
|
||||
<div className="auth-page__forgot-box">
|
||||
<p className="auth-page__forgot-title">重置密码</p>
|
||||
{forgotStep === "email" ? (
|
||||
<input value={forgotEmail} onChange={(e) => setForgotEmail(e.target.value)} placeholder="输入注册邮箱" type="email" className="auth-page__forgot-input" />
|
||||
) : forgotStep === "code" ? (
|
||||
<div className="auth-page__sms-row">
|
||||
<input value={forgotCode} onChange={(e) => setForgotCode(e.target.value)} placeholder="输入验证码" maxLength={6} />
|
||||
<button type="button" className="auth-page__sms-btn" disabled={emailCooldown > 0 || isSendingEmail} onClick={() => void handleSendEmailCode("reset")}>
|
||||
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "重新发送"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input type="password" value={forgotPassword} onChange={(e) => setForgotPassword(e.target.value)} placeholder="输入新密码(至少 6 位)" className="auth-page__forgot-input" />
|
||||
)}
|
||||
<div className="auth-page__forgot-actions">
|
||||
<button type="button" className="auth-page__forgot-cancel" onClick={() => { setShowForgotPassword(false); setForgotStep("email"); setForgotEmail(""); setForgotCode(""); setForgotPassword(""); setNotice(null); }}>取消</button>
|
||||
<button type="button" className="auth-page__forgot-confirm" onClick={() => void handleForgotPassword()}>
|
||||
{forgotStep === "newPassword" ? "重置密码" : "下一步"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showForgotPassword && authTab === "password" ? (
|
||||
<>
|
||||
<label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -818,16 +1052,21 @@ function ProfilePage({
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
|
||||
{mode === "register" && passwordLooksReady && !fieldErrors.password ? (
|
||||
<span className="auth-page__field-hint">
|
||||
<CheckCircleFilled /> 密码长度符合要求
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
{mode === "login" ? (
|
||||
<div className="auth-page__forgot">
|
||||
<button type="button">忘记密码?</button>
|
||||
<button type="button" onClick={() => { setShowForgotPassword(true); setForgotStep("email"); }}>忘记密码?</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{authTab === "email" ? (
|
||||
{!showForgotPassword && authTab === "email" ? (
|
||||
<>
|
||||
{mode === "register" ? (
|
||||
<label className="auth-page__field">
|
||||
@@ -855,6 +1094,11 @@ function ProfilePage({
|
||||
autoComplete="email"
|
||||
/>
|
||||
{fieldErrors.email ? <span className="auth-page__field-error">{fieldErrors.email}</span> : null}
|
||||
{emailLooksValid && !fieldErrors.email ? (
|
||||
<span className="auth-page__field-hint">
|
||||
<CheckCircleFilled /> 邮箱格式正确
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className={`auth-page__field${fieldErrors.password ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -869,11 +1113,16 @@ function ProfilePage({
|
||||
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
||||
/>
|
||||
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
|
||||
{mode === "register" && passwordLooksReady && !fieldErrors.password ? (
|
||||
<span className="auth-page__field-hint">
|
||||
<CheckCircleFilled /> 密码长度符合要求
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{authTab === "phone" ? (
|
||||
{!showForgotPassword && authTab === "phone" ? (
|
||||
<>
|
||||
<label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -884,6 +1133,11 @@ function ProfilePage({
|
||||
<input type="tel" value={phone} onChange={(event) => { setPhone(event.target.value); clearFieldError("phone"); }} onBlur={() => handleFieldBlur("phone", phone)} placeholder="输入手机号" autoComplete="tel" />
|
||||
</div>
|
||||
{fieldErrors.phone ? <span className="auth-page__field-error">{fieldErrors.phone}</span> : null}
|
||||
{phoneLooksValid && !fieldErrors.phone ? (
|
||||
<span className="auth-page__field-hint">
|
||||
<CheckCircleFilled /> 手机号格式正确
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className={`auth-page__field${fieldErrors.smsCode ? " auth-page__field--error" : ""}`}>
|
||||
<span>
|
||||
@@ -909,14 +1163,21 @@ function ProfilePage({
|
||||
</span>
|
||||
<input type="password" value={password} onChange={(event) => { setPassword(event.target.value); clearFieldError("password"); }} onBlur={() => handleFieldBlur("password", password)} placeholder="至少 6 位" autoComplete="new-password" />
|
||||
{fieldErrors.password ? <span className="auth-page__field-error">{fieldErrors.password}</span> : null}
|
||||
{passwordLooksReady && !fieldErrors.password ? (
|
||||
<span className="auth-page__field-hint">
|
||||
<CheckCircleFilled /> 密码长度符合要求
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{notice ? <p className="auth-page__notice">{notice}</p> : null}
|
||||
{!showForgotPassword ? (
|
||||
<>
|
||||
{notice ? <p className="auth-page__notice">{notice}</p> : null}
|
||||
|
||||
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
|
||||
<button type="submit" className="auth-page__submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
|
||||
</button>
|
||||
|
||||
@@ -936,6 +1197,8 @@ function ProfilePage({
|
||||
<MobileOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
BarChartOutlined,
|
||||
CheckCircleFilled,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
LoadingOutlined,
|
||||
ThunderboltOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
@@ -33,6 +36,8 @@ interface HistoryEntry {
|
||||
timestamp: number;
|
||||
score: number;
|
||||
grade: string;
|
||||
script?: string;
|
||||
result?: EvalResult;
|
||||
}
|
||||
|
||||
function getGrade(score: number): string {
|
||||
@@ -54,6 +59,8 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
".fountain",
|
||||
".fdx",
|
||||
".rtf",
|
||||
".docx",
|
||||
".doc",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
@@ -99,7 +106,7 @@ const TEXT_FILE_EXTENSIONS = [
|
||||
] as const;
|
||||
const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS);
|
||||
const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(",");
|
||||
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||
|
||||
function loadHistory(): HistoryEntry[] {
|
||||
try {
|
||||
@@ -168,6 +175,68 @@ 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`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const SCORE_DIMENSIONS: ScoreDimension[] = [
|
||||
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
|
||||
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
|
||||
@@ -222,6 +291,7 @@ function ScriptTokensPage() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [activeDim, setActiveDim] = useState<number | null>(null);
|
||||
const [animatedScore, setAnimatedScore] = useState(0);
|
||||
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const scoreFrameRef = useRef<number | null>(null);
|
||||
@@ -251,7 +321,23 @@ function ScriptTokensPage() {
|
||||
const ext = getFileExtension(file.name);
|
||||
const readable = isReadableTextFile(file, ext);
|
||||
setUploadedFile({ name: file.name, size: file.size });
|
||||
if (readable) {
|
||||
if (ext === ".docx") {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const text = await extractDocxText(bytes);
|
||||
if (text) {
|
||||
setScript(text);
|
||||
} else {
|
||||
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
||||
}
|
||||
} catch {
|
||||
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
||||
}
|
||||
} 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);
|
||||
} else {
|
||||
@@ -277,6 +363,8 @@ function ScriptTokensPage() {
|
||||
timestamp: Date.now(),
|
||||
score: aiResult.totalScore,
|
||||
grade: g,
|
||||
script,
|
||||
result: aiResult,
|
||||
};
|
||||
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
@@ -289,6 +377,20 @@ function ScriptTokensPage() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleHistoryClick = (item: HistoryEntry, index: number) => {
|
||||
setActiveHistoryIndex(index);
|
||||
if (item.script) {
|
||||
setScript(item.script);
|
||||
setUploadedFile({ name: `${item.name}.txt`, size: item.script.length });
|
||||
}
|
||||
if (item.result) {
|
||||
setResult(item.result);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
setEvalError(null);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setScript("");
|
||||
setResult(null);
|
||||
@@ -346,9 +448,10 @@ function ScriptTokensPage() {
|
||||
const compactTitle = uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? "剧本评测";
|
||||
const scriptMinutes = Math.max(8, Math.round(script.length / 460));
|
||||
const reportDate = new Date().toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" });
|
||||
const statusClass = loading ? "is-loading" : result ? "is-complete" : hasContent ? "is-ready" : "is-idle";
|
||||
|
||||
return (
|
||||
<section className="script-eval-v5 page-motion">
|
||||
<section className={`script-eval-v5 page-motion ${statusClass}`}>
|
||||
<div className="script-eval-v5-page">
|
||||
{/* Left Panel */}
|
||||
<aside className="script-eval-v5-left">
|
||||
@@ -364,7 +467,10 @@ function ScriptTokensPage() {
|
||||
{uploadedFile ? (
|
||||
<div className="script-eval-v5-upload-done is-show">
|
||||
<CheckCircleFilled />
|
||||
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
|
||||
<span className="script-eval-v5-uf-meta">
|
||||
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
|
||||
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
|
||||
</span>
|
||||
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
|
||||
重新上传
|
||||
</span>
|
||||
@@ -374,7 +480,7 @@ function ScriptTokensPage() {
|
||||
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
|
||||
<div className="script-eval-v5-upload-text">拖拽或点击上传</div>
|
||||
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
|
||||
+ 上传剧本
|
||||
<UploadOutlined /> 选择剧本
|
||||
</button>
|
||||
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
|
||||
</>
|
||||
@@ -420,7 +526,9 @@ function ScriptTokensPage() {
|
||||
<div className="script-eval-v5-history-empty">暂无评测记录</div>
|
||||
) : (
|
||||
history.map((item, i) => (
|
||||
<div key={i} className={`script-eval-v5-history-item${i === 0 ? " is-active" : ""}`}>
|
||||
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
|
||||
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
|
||||
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
|
||||
<div className="script-eval-v5-hi-left">
|
||||
<div className="script-eval-v5-hi-name">{item.name}</div>
|
||||
<div className="script-eval-v5-hi-date">{item.date}</div>
|
||||
@@ -445,10 +553,12 @@ function ScriptTokensPage() {
|
||||
disabled={loading || !hasContent}
|
||||
onClick={() => void handleEvaluate()}
|
||||
>
|
||||
{loading ? "◆ 评测中..." : "◆ 开始评测"}
|
||||
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
||||
<span>{loading ? "评测中..." : "开始评测"}</span>
|
||||
</button>
|
||||
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
||||
导出评测报告
|
||||
<DownloadOutlined />
|
||||
<span>导出评测报告</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -482,6 +592,11 @@ function ScriptTokensPage() {
|
||||
<div className="page-loading-spinner" />
|
||||
<strong>AI 正在分析剧本...</strong>
|
||||
<p>正在调用模型进行六维评分,预计需要 15-30 秒</p>
|
||||
<div className="script-eval-v5-loading-steps" aria-hidden="true">
|
||||
<span>结构识别</span>
|
||||
<span>冲突评估</span>
|
||||
<span>商业潜力</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -568,13 +683,23 @@ function ScriptTokensPage() {
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div className="script-eval-report__chart-grid">
|
||||
{SCORE_DIMENSIONS.map((dim) => {
|
||||
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
|
||||
const score = result.dimensionScores[dim.key] ?? 0;
|
||||
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
|
||||
const lossPct = 1 - pct;
|
||||
const isPerfect = score === dim.maxScore;
|
||||
const isActive = activeDim === null || activeDim === dimIndex;
|
||||
return (
|
||||
<button key={dim.key} type="button" className="script-eval-report__bar-col">
|
||||
<button
|
||||
key={dim.key}
|
||||
type="button"
|
||||
className={`script-eval-report__bar-col${isActive ? "" : " is-dimmed"}`}
|
||||
onMouseEnter={() => setActiveDim(dimIndex)}
|
||||
onFocus={() => setActiveDim(dimIndex)}
|
||||
onMouseLeave={() => setActiveDim(null)}
|
||||
onBlur={() => setActiveDim(null)}
|
||||
aria-label={`${dim.label} ${score}/${dim.maxScore},${dim.hint}`}
|
||||
>
|
||||
<div className="script-eval-report__bar-score">
|
||||
<b>{score}</b><small>/{dim.maxScore}</small>{isPerfect ? <em>*</em> : null}
|
||||
</div>
|
||||
@@ -589,6 +714,14 @@ function ScriptTokensPage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="script-eval-report__chart-note">
|
||||
<BarChartOutlined />
|
||||
<span>
|
||||
{activeDim === null
|
||||
? "悬停维度可查看当前分项表现,优先从低分项制定改稿计划。"
|
||||
: `${SCORE_DIMENSIONS[activeDim].label}:${SCORE_DIMENSIONS[activeDim].detail}`}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="script-eval-report__findings">
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LineChartOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
WarningOutlined,
|
||||
@@ -143,29 +142,22 @@ 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");
|
||||
|
||||
const refreshEnterpriseUsage = useCallback(async () => {
|
||||
if (!session) return;
|
||||
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
|
||||
if (!loader) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(null);
|
||||
return;
|
||||
}
|
||||
setEnterpriseUsageLoading(true);
|
||||
setEnterpriseUsageError(null);
|
||||
try {
|
||||
setEnterpriseUsage(await loader());
|
||||
} catch (error) {
|
||||
setEnterpriseUsage(null);
|
||||
setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
|
||||
} finally {
|
||||
setEnterpriseUsageLoading(false);
|
||||
}
|
||||
}, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEnterpriseUsage();
|
||||
@@ -230,45 +222,51 @@ function TokenUsagePage({
|
||||
{ label: "账户类型", value: isEnterpriseAccount ? "企业账户" : "个人账户", tone: "good" },
|
||||
{ label: "企业空间", value: enterpriseUsage?.enterpriseName || session?.user.enterpriseName || "-" },
|
||||
];
|
||||
const pageStatusClass = enterpriseUsageLoading
|
||||
? "is-syncing"
|
||||
: enterpriseUsageError
|
||||
? "has-sync-error"
|
||||
: isLowBalance
|
||||
? "has-low-balance"
|
||||
: "is-healthy";
|
||||
|
||||
return (
|
||||
<section className="script-token-page token-usage-page management-center-page" aria-label="管理中心">
|
||||
<section className={`script-token-page token-usage-page management-center-page ${pageStatusClass}`} aria-label="管理中心">
|
||||
<main className="management-center-shell">
|
||||
<header className="management-center-toolbar" aria-label="管理中心操作">
|
||||
<div className="management-center-toolbar__title">
|
||||
<button type="button" className="management-center-toolbar__back" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<ArrowLeftOutlined />
|
||||
</button>
|
||||
<strong>管理中心</strong>
|
||||
<span>
|
||||
<strong>管理中心</strong>
|
||||
<small>用量、成员与模型调用监控</small>
|
||||
</span>
|
||||
</div>
|
||||
<span className="management-center-status-pill">
|
||||
<span className={`management-center-status-pill ${enterpriseUsageError ? "is-error" : enterpriseUsageLoading ? "is-loading" : "is-online"}`}>
|
||||
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
||||
</span>
|
||||
<button type="button" onClick={refreshEnterpriseUsage}>
|
||||
<button type="button" onClick={refreshEnterpriseUsage} disabled={enterpriseUsageLoading}>
|
||||
<ReloadOutlined />
|
||||
刷新数据
|
||||
</button>
|
||||
<button type="button">
|
||||
<button type="button" className="is-muted-action">
|
||||
<UserOutlined />
|
||||
成员管理
|
||||
</button>
|
||||
<button type="button" className="is-primary" onClick={() => onSelectView?.("settings")}>
|
||||
<SettingOutlined />
|
||||
服务设置
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{isLowBalance ? (
|
||||
<div className="management-balance-alert" role="alert">
|
||||
<WarningOutlined />
|
||||
<span>当前余额 {formatCredits(availableBalanceCents)},可能不足以完成下一次生成,请及时充值。</span>
|
||||
<button type="button" onClick={() => onSelectView?.("settings")}>去充值</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="management-metric-cards" aria-label="关键指标">
|
||||
{metricCards.map((card) => (
|
||||
{metricCards.map((card, index) => (
|
||||
<article key={card.key} className={`management-metric-card is-${card.tone}`}>
|
||||
<span className="management-metric-card__index">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="management-metric-card__label">{card.label}</span>
|
||||
<strong className="management-metric-card__value">{card.value}</strong>
|
||||
<span className="management-metric-card__hint">{card.hint}</span>
|
||||
@@ -283,7 +281,7 @@ function TokenUsagePage({
|
||||
<BarChartOutlined />
|
||||
模型消耗分布
|
||||
</h2>
|
||||
<span>{modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
|
||||
<span>{enterpriseUsageLoading ? "SYNC" : modelBreakdown.length ? `${modelBreakdown.length} 个模型` : "LIVE"}</span>
|
||||
</div>
|
||||
{modelBreakdown.length ? (
|
||||
<div className="management-model-list">
|
||||
@@ -310,7 +308,10 @@ function TokenUsagePage({
|
||||
|
||||
<article className="management-card management-status-card">
|
||||
<div className="management-card__head">
|
||||
<h2>系统状态</h2>
|
||||
<h2>
|
||||
<LineChartOutlined />
|
||||
系统状态
|
||||
</h2>
|
||||
</div>
|
||||
<dl>
|
||||
{systemStatus.map((item) => (
|
||||
@@ -364,7 +365,10 @@ function TokenUsagePage({
|
||||
|
||||
<section className="management-card management-records">
|
||||
<div className="management-card__head">
|
||||
<h2>调用记录</h2>
|
||||
<h2>
|
||||
<BarChartOutlined />
|
||||
调用记录
|
||||
</h2>
|
||||
<span>{records.length} 条记录</span>
|
||||
</div>
|
||||
<div className="management-record-table" role="table" aria-label="调用记录">
|
||||
|
||||
@@ -41,6 +41,7 @@ import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUp
|
||||
import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
|
||||
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
|
||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
@@ -236,8 +237,10 @@ 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" });
|
||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||
const hasHandledInitialMessagesRef = useRef(false);
|
||||
|
||||
@@ -273,6 +276,8 @@ 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(() => {
|
||||
@@ -441,6 +446,27 @@ 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;
|
||||
@@ -451,6 +477,7 @@ function WorkbenchPage({
|
||||
|
||||
setComposerHidden(false);
|
||||
shouldFollowNewMessagesRef.current = true;
|
||||
revealScrollActionsTemporarily("bottom");
|
||||
surface.scrollTo({ top: surface.scrollHeight, behavior });
|
||||
lastScrollTopRef.current = surface.scrollTop;
|
||||
};
|
||||
@@ -459,7 +486,7 @@ function WorkbenchPage({
|
||||
scroll();
|
||||
window.setTimeout(scroll, 80);
|
||||
});
|
||||
}, []);
|
||||
}, [revealScrollActionsTemporarily]);
|
||||
|
||||
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
|
||||
() => [
|
||||
@@ -1373,6 +1400,9 @@ 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);
|
||||
@@ -1384,7 +1414,7 @@ function WorkbenchPage({
|
||||
|
||||
surface.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => surface.removeEventListener("scroll", handleScroll);
|
||||
}, [hasActivatedWorkspace]);
|
||||
}, [hasActivatedWorkspace, revealScrollActionsTemporarily]);
|
||||
|
||||
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
|
||||
const surface = messagesSurfaceRef.current;
|
||||
@@ -1392,8 +1422,9 @@ 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>) => {
|
||||
@@ -1851,6 +1882,7 @@ function WorkbenchPage({
|
||||
referenceUrls: refUrls.length ? refUrls : undefined,
|
||||
});
|
||||
taskId = result.taskId;
|
||||
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
|
||||
} else {
|
||||
let requestModel = resolveVideoRequestModel({
|
||||
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
@@ -1870,6 +1902,7 @@ function WorkbenchPage({
|
||||
hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"),
|
||||
});
|
||||
taskId = result.taskId;
|
||||
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "video", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
|
||||
}
|
||||
|
||||
onRefreshUsage?.();
|
||||
@@ -3022,10 +3055,13 @@ function WorkbenchPage({
|
||||
{renderComposerToolbar(false, isGenerating)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
|
||||
<div
|
||||
className={`wb-chat-scroll-actions${scrollActionsVisible ? " is-visible" : ""}${scrollActionDirection ? ` is-${scrollActionDirection}` : ""}`}
|
||||
aria-label="聊天滚动"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="wb-chat-scroll-actions__button"
|
||||
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--top"
|
||||
title="返回聊天顶部"
|
||||
aria-label="返回聊天顶部"
|
||||
onClick={() => scrollMessagesSurface("top")}
|
||||
@@ -3034,7 +3070,7 @@ function WorkbenchPage({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wb-chat-scroll-actions__button"
|
||||
className="wb-chat-scroll-actions__button wb-chat-scroll-actions__button--bottom"
|
||||
title="到达聊天底部"
|
||||
aria-label="到达聊天底部"
|
||||
onClick={() => scrollMessagesSurface("bottom")}
|
||||
|
||||
@@ -3,6 +3,8 @@ export { useSessionStore } from './useSessionStore';
|
||||
export { useProjectStore } from './useProjectStore';
|
||||
export { useTaskStore } from './useTaskStore';
|
||||
export { useAppStore } from './useAppStore';
|
||||
export { useGenerationStore } from './useGenerationStore';
|
||||
export type { GenerationQueueItem, QueueItemStatus } from './useGenerationStore';
|
||||
|
||||
// Type exports
|
||||
export type { PendingAction } from './useSessionStore';
|
||||
|
||||
@@ -365,11 +365,113 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.recharge-modal__checkout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.26);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), rgba(var(--accent-rgb), 0.05));
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.recharge-modal__checkout-eyebrow {
|
||||
color: var(--accent, #34d399);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.recharge-modal__checkout h3,
|
||||
.recharge-modal__checkout p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recharge-modal__checkout h3 {
|
||||
margin-top: 4px;
|
||||
color: var(--fg-body, #edf2f7);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.recharge-modal__checkout p {
|
||||
color: var(--fg-muted, #9ba7b7);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recharge-modal__payment-methods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.recharge-modal__payment-methods button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-height: 68px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle, rgb(255 255 255 / 10%));
|
||||
border-radius: 12px;
|
||||
background: var(--bg-inset, rgb(0 0 0 / 18%));
|
||||
color: var(--fg-body, #edf2f7);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.recharge-modal__payment-methods button.is-active {
|
||||
border-color: rgba(var(--accent-rgb), 0.56);
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
}
|
||||
|
||||
.recharge-modal__payment-methods span {
|
||||
color: var(--fg-muted, #9ba7b7);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recharge-modal__pay {
|
||||
min-height: 42px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--accent, #34d399);
|
||||
color: #07110d;
|
||||
cursor: pointer;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.recharge-modal__pay:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.recharge-modal__order {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle, rgb(255 255 255 / 10%));
|
||||
border-radius: 12px;
|
||||
background: var(--bg-inset, rgb(0 0 0 / 18%));
|
||||
color: var(--fg-muted, #9ba7b7);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.recharge-modal__order strong,
|
||||
.recharge-modal__order a {
|
||||
color: var(--accent, #34d399);
|
||||
}
|
||||
|
||||
.recharge-modal__order img {
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.recharge-modal__grid[data-audience="personal"],
|
||||
.recharge-modal__grid[data-audience="enterprise"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.recharge-modal__payment-methods {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
@import "./pages/compliance.css";
|
||||
@import "./pages/provider-health.css";
|
||||
@import "./pages/legacy-pages.css";
|
||||
@import "./pages/not-found.css";
|
||||
@import "./components/recharge-modal.css";
|
||||
@import "./components/dropzone.css";
|
||||
@import "./components/skeleton.css";
|
||||
|
||||
@@ -189,6 +189,40 @@
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.asset-card-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.asset-card__delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 150ms, color 150ms;
|
||||
}
|
||||
|
||||
.asset-card-wrapper:hover .asset-card__delete {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.asset-card__delete:hover {
|
||||
opacity: 1;
|
||||
color: var(--fg-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.asset-preview-modal {
|
||||
padding: 14px;
|
||||
|
||||
@@ -725,9 +725,110 @@
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.compliance-page {
|
||||
min-height: 100%;
|
||||
background: #0d0d0f;
|
||||
color: var(--fg-body);
|
||||
}
|
||||
|
||||
.compliance-page__inner {
|
||||
width: min(940px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 56px;
|
||||
}
|
||||
|
||||
.compliance-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.compliance-hero__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 54px;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.28);
|
||||
border-radius: 16px;
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.compliance-hero__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.compliance-hero h1 {
|
||||
margin: 4px 0 8px;
|
||||
font-size: clamp(26px, 4vw, 38px);
|
||||
}
|
||||
|
||||
.compliance-hero p,
|
||||
.compliance-section p,
|
||||
.compliance-contact span {
|
||||
color: var(--fg-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.compliance-card,
|
||||
.compliance-contact {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 18px;
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-tight);
|
||||
}
|
||||
|
||||
.compliance-card {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compliance-section {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 22px;
|
||||
border-bottom: 1px solid var(--border-weak);
|
||||
}
|
||||
|
||||
.compliance-section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.compliance-section > span {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.compliance-section h2,
|
||||
.compliance-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compliance-section h2 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.compliance-contact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 16px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.community-review-page__inner,
|
||||
.report-page__inner {
|
||||
.report-page__inner,
|
||||
.compliance-page__inner {
|
||||
width: min(100% - 28px, 720px);
|
||||
padding-top: 24px;
|
||||
}
|
||||
@@ -786,4 +887,9 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.compliance-hero,
|
||||
.compliance-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2809,6 +2809,26 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.clone-ai-retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.32);
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
color: var(--accent);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.clone-ai-retry-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.22);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 300px) 54px minmax(330px, 560px);
|
||||
@@ -8061,4 +8081,889 @@
|
||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card,
|
||||
.ecommerce-template-apple-carousel.is-resetting .ecommerce-template-apple-card img {
|
||||
transition: none;
|
||||
.clone-ai-video-outfit-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.clone-ai-video-outfit-upload-btn {
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--fg-body);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms, background 150ms;
|
||||
}
|
||||
|
||||
.clone-ai-video-outfit-upload-btn:hover {
|
||||
border-color: var(--border-default);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.clone-ai-video-outfit-info {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
|
||||
.product-clone-page {
|
||||
--ecm-page: #0e1012;
|
||||
--ecm-panel: rgba(20, 23, 25, 0.96);
|
||||
--ecm-panel-strong: rgba(24, 28, 30, 0.98);
|
||||
--ecm-inset: rgba(255, 255, 255, 0.035);
|
||||
--ecm-inset-hover: rgba(255, 255, 255, 0.06);
|
||||
--ecm-line: rgba(255, 255, 255, 0.095);
|
||||
--ecm-line-strong: rgba(255, 255, 255, 0.14);
|
||||
--ecm-text: #eef4f0;
|
||||
--ecm-muted: rgba(232, 240, 235, 0.62);
|
||||
--ecm-soft: rgba(232, 240, 235, 0.42);
|
||||
--ecm-accent: var(--accent, #00ff88);
|
||||
--ecm-accent-rgb: var(--accent-rgb, 0, 255, 136);
|
||||
--ecm-radius-sm: 10px;
|
||||
--ecm-radius-md: 14px;
|
||||
--ecm-radius-lg: 18px;
|
||||
--ecm-shadow-soft: 0 14px 38px rgba(0, 0, 0, 0.2);
|
||||
--ecm-shadow-panel: 0 18px 54px rgba(0, 0, 0, 0.28);
|
||||
background:
|
||||
radial-gradient(circle at 26% 0%, rgba(var(--ecm-accent-rgb), 0.055), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.022), transparent 150px),
|
||||
var(--ecm-page);
|
||||
color: var(--ecm-text);
|
||||
font-family: var(--font-sans, Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif);
|
||||
}
|
||||
|
||||
.product-clone-page > .product-clone-shell {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.018), transparent 22%, transparent 76%, rgba(255, 255, 255, 0.014)),
|
||||
transparent;
|
||||
}
|
||||
|
||||
.product-clone-page :is(button, select, textarea, input) {
|
||||
font-family: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.product-clone-page :is(button, select, textarea):focus-visible {
|
||||
outline: 2px solid rgba(var(--ecm-accent-rgb), 0.48);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.product-clone-page :is(button, select, textarea):disabled {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] {
|
||||
--clone-settings-panel-width: clamp(420px, 36vw, 540px);
|
||||
background:
|
||||
radial-gradient(circle at 72% 12%, rgba(var(--ecm-accent-rgb), 0.045), transparent 31%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 180px),
|
||||
var(--ecm-page);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] > .product-clone-shell,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .product-clone-panel {
|
||||
border-right-color: var(--ecm-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 150px),
|
||||
var(--ecm-panel);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-panel {
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-logo {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
margin: -18px -18px 2px;
|
||||
padding: 16px 18px 14px;
|
||||
border-bottom-color: var(--ecm-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(21, 24, 26, 0.98), rgba(21, 24, 26, 0.9));
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-logo__mark {
|
||||
border-radius: 9px;
|
||||
box-shadow: 0 0 0 1px rgba(var(--ecm-accent-rgb), 0.18), 0 10px 24px rgba(var(--ecm-accent-rgb), 0.14);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-logo strong {
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-logo em {
|
||||
border-color: var(--ecm-line);
|
||||
background: var(--ecm-inset);
|
||||
color: var(--ecm-muted);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-card,
|
||||
.clone-ai-platform-spec,
|
||||
.clone-ai-count-panel,
|
||||
.clone-ai-replicate-panel,
|
||||
.clone-ai-module-panel,
|
||||
.clone-ai-model-panel,
|
||||
.clone-ai-video-panel
|
||||
) {
|
||||
border-color: var(--ecm-line);
|
||||
border-radius: var(--ecm-radius-md);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 58%),
|
||||
var(--ecm-panel-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-card {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-card h2 {
|
||||
margin-bottom: 10px;
|
||||
color: var(--ecm-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone {
|
||||
min-height: 140px;
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
border-radius: var(--ecm-radius-md);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(var(--ecm-accent-rgb), 0.09), transparent 58%),
|
||||
var(--ecm-inset);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:hover,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone.is-dragging {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.55);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(var(--ecm-accent-rgb), 0.14), transparent 60%),
|
||||
rgba(var(--ecm-accent-rgb), 0.055);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-upload-icon {
|
||||
background: rgba(var(--ecm-accent-rgb), 0.09);
|
||||
color: var(--ecm-accent);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-tag-group button,
|
||||
.clone-ai-basic-select > button,
|
||||
.clone-ai-basic-select__menu,
|
||||
.clone-ai-basic-select__menu button,
|
||||
.clone-ai-replicate-tabs button,
|
||||
.clone-ai-replicate-upload,
|
||||
.clone-ai-replicate-levels button,
|
||||
.clone-ai-count-row,
|
||||
.clone-ai-count-stepper button,
|
||||
.clone-ai-module-list button,
|
||||
.clone-ai-model-tabs button,
|
||||
.clone-ai-model-scene-grid button,
|
||||
.clone-ai-model-select,
|
||||
.clone-ai-model-select > button,
|
||||
.clone-ai-model-select__menu,
|
||||
.clone-ai-model-select__menu button,
|
||||
.clone-ai-video-options button,
|
||||
.clone-ai-video-smart
|
||||
) {
|
||||
border-color: var(--ecm-line);
|
||||
background: var(--ecm-inset);
|
||||
color: var(--ecm-muted);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-tag-group button:hover,
|
||||
.clone-ai-basic-select > button:hover,
|
||||
.clone-ai-basic-select > button.is-open,
|
||||
.clone-ai-basic-select__menu button:hover,
|
||||
.clone-ai-replicate-tabs button:hover,
|
||||
.clone-ai-replicate-upload:hover,
|
||||
.clone-ai-replicate-levels button:hover,
|
||||
.clone-ai-count-stepper button:hover:not(:disabled),
|
||||
.clone-ai-module-list button:hover,
|
||||
.clone-ai-model-tabs button:hover,
|
||||
.clone-ai-model-scene-grid button:hover,
|
||||
.clone-ai-model-select > button:hover,
|
||||
.clone-ai-model-select > button.is-open,
|
||||
.clone-ai-model-select__menu button:hover,
|
||||
.clone-ai-video-options button:hover,
|
||||
.clone-ai-video-smart:hover
|
||||
) {
|
||||
border-color: var(--ecm-line-strong);
|
||||
background: var(--ecm-inset-hover);
|
||||
color: var(--ecm-text);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-tag-group button.is-active,
|
||||
.clone-ai-basic-select__menu button.is-active,
|
||||
.clone-ai-replicate-tabs button.is-active,
|
||||
.clone-ai-replicate-levels button.is-active,
|
||||
.clone-ai-module-list button.is-active,
|
||||
.clone-ai-model-tabs button.is-active,
|
||||
.clone-ai-model-scene-grid button.is-active,
|
||||
.clone-ai-model-select__menu button.is-active,
|
||||
.clone-ai-video-options button.is-active,
|
||||
.clone-ai-video-smart.is-on
|
||||
) {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.48);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--ecm-accent-rgb), 0.16), rgba(var(--ecm-accent-rgb), 0.07));
|
||||
color: var(--ecm-accent);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate, .clone-ai-send-button, .clone-ai-upload-zone strong) {
|
||||
background: var(--ecm-accent);
|
||||
color: var(--dg-button-text, #061014);
|
||||
box-shadow: 0 10px 28px rgba(var(--ecm-accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate:hover:not(:disabled), .clone-ai-send-button:hover:not(:disabled)) {
|
||||
filter: brightness(1.03);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate:disabled, .clone-ai-send-button:disabled) {
|
||||
border-color: var(--ecm-line);
|
||||
background: var(--ecm-inset);
|
||||
color: var(--ecm-soft);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle {
|
||||
border-color: var(--ecm-line-strong);
|
||||
background: rgba(20, 23, 25, 0.86);
|
||||
color: var(--ecm-muted);
|
||||
box-shadow: var(--ecm-shadow-soft);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle:hover,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle:focus-visible {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.5);
|
||||
background: rgba(var(--ecm-accent-rgb), 0.09);
|
||||
color: var(--ecm-accent);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
align-content: center;
|
||||
gap: 20px;
|
||||
padding: 90px clamp(22px, 4vw, 46px) 134px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(var(--ecm-accent-rgb), 0.035), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.014), transparent 160px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
|
||||
top: 28px;
|
||||
right: clamp(22px, 4vw, 46px);
|
||||
left: clamp(22px, 4vw, 46px);
|
||||
max-width: none;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header strong {
|
||||
font-size: clamp(18px, 1.6vw, 22px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header span {
|
||||
max-width: 620px;
|
||||
color: var(--ecm-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
|
||||
width: min(100%, 580px);
|
||||
min-height: 260px;
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.055);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.014);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-empty-state .anticon {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-color: var(--ecm-line);
|
||||
background:
|
||||
radial-gradient(circle at 50% 16%, rgba(var(--ecm-accent-rgb), 0.12), transparent 62%),
|
||||
var(--ecm-panel-strong);
|
||||
color: rgba(var(--ecm-accent-rgb), 0.46);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(.clone-ai-main-result, .clone-ai-result-grid button) {
|
||||
border-color: var(--ecm-line);
|
||||
background: var(--ecm-panel-strong);
|
||||
box-shadow: var(--ecm-shadow-soft);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(.clone-ai-main-result:hover, .clone-ai-result-grid button:hover) {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.38);
|
||||
box-shadow: var(--ecm-shadow-panel);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-bottom-input {
|
||||
right: clamp(18px, 4vw, 46px);
|
||||
bottom: 20px;
|
||||
left: clamp(18px, 4vw, 46px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper {
|
||||
border-color: var(--ecm-line);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.018)),
|
||||
rgba(20, 24, 23, 0.92);
|
||||
box-shadow: var(--ecm-shadow-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper:focus-within {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.42);
|
||||
box-shadow:
|
||||
0 20px 58px rgba(0, 0, 0, 0.34),
|
||||
0 0 0 1px rgba(var(--ecm-accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper textarea {
|
||||
color: var(--ecm-text);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-char-count {
|
||||
color: var(--ecm-soft);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) {
|
||||
background: var(--ecm-page);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-panel,
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-rail {
|
||||
border-color: var(--ecm-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 160px),
|
||||
var(--ecm-panel);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="detail"], [data-tool="wear"]) .product-clone-rail button {
|
||||
border-radius: var(--ecm-radius-sm);
|
||||
color: var(--ecm-muted);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="detail"], [data-tool="wear"]) .product-clone-rail button:hover,
|
||||
.product-clone-page:is([data-tool="detail"], [data-tool="wear"]) .product-clone-rail button.is-active {
|
||||
background: rgba(var(--ecm-accent-rgb), 0.1);
|
||||
color: var(--ecm-accent);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-clone-field,
|
||||
.product-set-upload-section,
|
||||
.product-set-settings-section,
|
||||
.product-set-detail-section
|
||||
) {
|
||||
border-color: var(--ecm-line);
|
||||
border-radius: var(--ecm-radius-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.034), transparent 54%),
|
||||
var(--ecm-panel-strong);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-clone-field h2,
|
||||
.product-set-upload-section h2,
|
||||
.product-set-settings-section h2,
|
||||
.product-set-detail-section h2
|
||||
) {
|
||||
color: var(--ecm-text);
|
||||
font-size: 15px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
select,
|
||||
textarea,
|
||||
.product-clone-upload-zone,
|
||||
.product-set-upload,
|
||||
.product-set-output-grid button,
|
||||
.product-set-structure-grid button,
|
||||
.product-detail-module-grid button,
|
||||
.product-clone-scene-grid button,
|
||||
.product-clone-ratio-row button,
|
||||
.product-clone-segment button,
|
||||
.product-clone-model-button,
|
||||
.product-clone-switch-row,
|
||||
.product-set-style-toggle
|
||||
) {
|
||||
border-color: var(--ecm-line);
|
||||
border-radius: var(--ecm-radius-sm);
|
||||
background: var(--ecm-inset);
|
||||
color: var(--ecm-muted);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
select:hover,
|
||||
textarea:hover,
|
||||
.product-clone-upload-zone:hover,
|
||||
.product-set-upload:hover,
|
||||
.product-set-output-grid button:hover,
|
||||
.product-set-structure-grid button:hover,
|
||||
.product-detail-module-grid button:hover,
|
||||
.product-clone-scene-grid button:hover,
|
||||
.product-clone-ratio-row button:hover,
|
||||
.product-clone-segment button:hover,
|
||||
.product-clone-model-button:hover,
|
||||
.product-clone-switch-row:hover,
|
||||
.product-set-style-toggle:hover
|
||||
) {
|
||||
border-color: var(--ecm-line-strong);
|
||||
background: var(--ecm-inset-hover);
|
||||
color: var(--ecm-text);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-set-output-grid button.is-active,
|
||||
.product-set-structure-grid button.is-active,
|
||||
.product-detail-module-grid button.is-active,
|
||||
.product-clone-scene-grid button.is-active,
|
||||
.product-clone-ratio-row button.is-active,
|
||||
.product-clone-segment button.is-active,
|
||||
.product-set-style-toggle.is-active
|
||||
) {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.48);
|
||||
background: rgba(var(--ecm-accent-rgb), 0.12);
|
||||
color: var(--ecm-accent);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary, .product-set-floating-submit) {
|
||||
border: 0;
|
||||
background: var(--ecm-accent);
|
||||
color: var(--dg-button-text, #061014);
|
||||
box-shadow: 0 12px 30px rgba(var(--ecm-accent-rgb), 0.18);
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary, .product-set-floating-submit):disabled {
|
||||
background: var(--ecm-inset);
|
||||
color: var(--ecm-soft);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview {
|
||||
background:
|
||||
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
|
||||
transparent;
|
||||
}
|
||||
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-set-empty-preview,
|
||||
.product-clone-empty-panel
|
||||
) {
|
||||
border-color: rgba(255, 255, 255, 0.055);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.014);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="set"] .product-set-floating-detail {
|
||||
border-color: var(--ecm-line);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.018)),
|
||||
rgba(20, 24, 23, 0.92);
|
||||
box-shadow: var(--ecm-shadow-panel);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.ecommerce-progress-bar {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.22);
|
||||
background: rgba(var(--ecm-accent-rgb), 0.07);
|
||||
}
|
||||
|
||||
.ecommerce-progress-bar__fill {
|
||||
box-shadow: 0 0 18px rgba(var(--ecm-accent-rgb), 0.36);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.product-clone-page[data-tool="clone"] {
|
||||
--clone-settings-panel-width: clamp(390px, 45vw, 440px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
padding-right: 28px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.product-clone-page[data-tool="clone"] {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] > .product-clone-shell,
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed > .product-clone-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(620px, 1fr);
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed > .product-clone-shell {
|
||||
grid-template-rows: 0 minmax(620px, 1fr);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .product-clone-panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--ecm-line);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-panel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle,
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed .clone-ai-settings-toggle {
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
min-height: 620px;
|
||||
padding: 92px 18px 134px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
|
||||
top: 24px;
|
||||
right: 18px;
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
|
||||
grid-template-columns: 1fr;
|
||||
width: min(100%, 520px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-flow-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-main-result {
|
||||
height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-panel__scroll,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-logo {
|
||||
margin: -14px -14px 0;
|
||||
padding: 14px 54px 12px 14px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-card,
|
||||
.product-clone-page[data-tool="clone"] :is(.clone-ai-platform-spec, .clone-ai-count-panel, .clone-ai-replicate-panel, .clone-ai-module-panel, .clone-ai-model-panel, .clone-ai-video-panel) {
|
||||
padding: 11px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-tag-group,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-basic-select-grid,
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-model-select-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
min-height: 560px;
|
||||
padding: 86px 12px 128px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
|
||||
min-height: 220px;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-bottom-input {
|
||||
right: 10px;
|
||||
bottom: 12px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-input-wrapper {
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-send-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.product-clone-page {
|
||||
box-sizing: border-box;
|
||||
padding-top: 58px;
|
||||
}
|
||||
|
||||
.product-clone-page > .product-clone-shell {
|
||||
min-height: calc(100% - 58px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.product-clone-page {
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.product-clone-page > .product-clone-shell {
|
||||
min-height: calc(100% - 56px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ecommerce refinement pass: make the preview state more informative and selected controls quieter. */
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
padding-top: 138px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
max-width: min(100%, 720px);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 180px;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.095);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: rgba(232, 240, 235, 0.68);
|
||||
font-size: 11px;
|
||||
font-weight: 780;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary span:first-child {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.28);
|
||||
background: rgba(var(--ecm-accent-rgb), 0.08);
|
||||
color: var(--ecm-accent);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-module-list button,
|
||||
.clone-ai-model-scene-grid button,
|
||||
.clone-ai-replicate-levels button,
|
||||
.clone-ai-video-options button
|
||||
),
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-set-structure-grid button,
|
||||
.product-detail-module-grid button,
|
||||
.product-clone-scene-grid button,
|
||||
.product-clone-ratio-row button
|
||||
) {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-module-list button.is-active,
|
||||
.clone-ai-model-scene-grid button.is-active,
|
||||
.clone-ai-replicate-levels button.is-active,
|
||||
.clone-ai-video-options button.is-active
|
||||
),
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-set-structure-grid button.is-active,
|
||||
.product-detail-module-grid button.is-active,
|
||||
.product-clone-scene-grid button.is-active,
|
||||
.product-clone-ratio-row button.is-active
|
||||
) {
|
||||
border-color: rgba(var(--ecm-accent-rgb), 0.5);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(var(--ecm-accent-rgb), 0.13), rgba(var(--ecm-accent-rgb), 0.035)),
|
||||
rgba(255, 255, 255, 0.035);
|
||||
color: var(--ecm-text);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] :is(
|
||||
.clone-ai-module-list button.is-active,
|
||||
.clone-ai-model-scene-grid button.is-active,
|
||||
.clone-ai-replicate-levels button.is-active,
|
||||
.clone-ai-video-options button.is-active
|
||||
)::before,
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-set-structure-grid button.is-active,
|
||||
.product-detail-module-grid button.is-active,
|
||||
.product-clone-scene-grid button.is-active,
|
||||
.product-clone-ratio-row button.is-active
|
||||
)::before {
|
||||
position: absolute;
|
||||
inset: 8px auto 8px 0;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: var(--ecm-accent);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-module-list button.is-active span,
|
||||
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(
|
||||
.product-set-structure-grid button.is-active em,
|
||||
.product-detail-module-grid button.is-active span
|
||||
) {
|
||||
color: rgba(232, 240, 235, 0.62);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
padding-top: 148px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
padding-top: 158px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-summary span {
|
||||
max-width: 138px;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed .clone-ai-preview {
|
||||
grid-template-rows: auto minmax(220px, 1fr) auto;
|
||||
align-content: stretch;
|
||||
justify-items: stretch;
|
||||
overflow: auto;
|
||||
padding-top: clamp(28px, 7vh, 72px);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed .clone-ai-preview-header {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed .clone-ai-empty-state {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
min-height: min(260px, 36vh);
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"].is-settings-collapsed .clone-ai-bottom-input {
|
||||
position: static;
|
||||
width: min(100%, 780px);
|
||||
align-self: end;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
grid-template-rows: auto minmax(220px, 1fr) auto;
|
||||
align-content: stretch;
|
||||
justify-items: stretch;
|
||||
overflow: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview-header {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-bottom-input {
|
||||
position: static;
|
||||
width: min(100%, 780px);
|
||||
align-self: end;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
grid-template-rows: auto minmax(210px, 1fr) auto;
|
||||
gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-empty-state {
|
||||
min-height: min(220px, 34vh);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile preview rhythm: once the preview header is in normal flow, remove the desktop top reserve. */
|
||||
@media (max-width: 860px) {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
min-height: 520px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.product-clone-page[data-tool="clone"] .clone-ai-preview {
|
||||
min-height: 440px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
+62
-16
@@ -148,37 +148,83 @@
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
padding: 0 28px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
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);
|
||||
color: var(--fg-body);
|
||||
cursor: pointer;
|
||||
font-size: 17px;
|
||||
font-weight: 850;
|
||||
transition: border-color 160ms ease, background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
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;
|
||||
}
|
||||
|
||||
.omni-home__entry .anticon {
|
||||
font-size: 18px;
|
||||
font-size: 19px;
|
||||
transition: color 240ms ease, transform 240ms ease;
|
||||
}
|
||||
|
||||
.omni-home__entry:hover {
|
||||
border-color: var(--border-default);
|
||||
background: var(--bg-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);
|
||||
color: #ffffff;
|
||||
transform: translateY(-1px);
|
||||
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;
|
||||
}
|
||||
|
||||
.omni-home__entry--primary {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--dg-button-text, #061014);
|
||||
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;
|
||||
}
|
||||
|
||||
.omni-home__entry--primary:hover {
|
||||
border-color: var(--accent-hover, var(--accent));
|
||||
background: var(--accent-hover, var(--accent));
|
||||
color: var(--dg-button-text, #061014);
|
||||
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);
|
||||
}
|
||||
|
||||
.omni-home__carousel {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.not-found-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
padding: 48px 24px;
|
||||
background: var(--app-bg, #0b0b0f);
|
||||
}
|
||||
|
||||
.not-found-page__content {
|
||||
text-align: center;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.not-found-page__code {
|
||||
font-size: 96px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--accent-teal, #2dd4bf);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.not-found-page h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.not-found-page p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
margin: 0 0 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.not-found-page__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
border: 1px solid var(--border-default, #334155);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-elevated, #1e293b);
|
||||
color: var(--text-primary, #f1f5f9);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.not-found-page__button:hover {
|
||||
background: var(--surface-hover, #334155);
|
||||
border-color: var(--accent-teal, #2dd4bf);
|
||||
}
|
||||
@@ -2649,3 +2649,775 @@
|
||||
min-height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Commercial SaaS polish for script review page ===== */
|
||||
.script-eval-v5 {
|
||||
--v5-radius-xs: 6px;
|
||||
--v5-radius-sm: 8px;
|
||||
--v5-radius-md: 12px;
|
||||
--v5-radius-lg: 16px;
|
||||
--v5-panel: #131616;
|
||||
--v5-panel-2: #181c1b;
|
||||
--v5-panel-3: #101312;
|
||||
--v5-line: rgb(255 255 255 / 7%);
|
||||
--v5-line-strong: rgb(0 255 136 / 22%);
|
||||
--v5-shadow-soft: 0 18px 48px rgb(0 0 0 / 24%);
|
||||
--v5-shadow-tight: 0 10px 24px rgb(0 0 0 / 18%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 2.2%), transparent 220px),
|
||||
var(--v5-bg);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.script-eval-v5-page {
|
||||
background: linear-gradient(90deg, rgb(0 255 136 / 3%), transparent 28%);
|
||||
}
|
||||
|
||||
.script-eval-v5-left {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 3.5%), transparent 180px),
|
||||
var(--v5-panel);
|
||||
border-right-color: var(--v5-line);
|
||||
box-shadow: inset -1px 0 0 rgb(0 255 136 / 4%), 18px 0 38px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-section {
|
||||
border-bottom-color: var(--v5-line);
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #7f8d88;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-label::before {
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgb(0 255 136 / 42%);
|
||||
box-shadow: 0 0 14px rgb(0 255 136 / 24%);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-zone {
|
||||
position: relative;
|
||||
min-height: 214px;
|
||||
border: 1px dashed rgb(255 255 255 / 14%);
|
||||
border-radius: var(--v5-radius-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent),
|
||||
rgb(255 255 255 / 2.5%);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-zone:hover,
|
||||
.script-eval-v5-upload-zone:focus-visible {
|
||||
border-color: var(--v5-line-strong);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(0 255 136 / 8%), transparent),
|
||||
rgb(0 255 136 / 4%);
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), 0 16px 36px rgb(0 0 0 / 16%);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
margin: 0 auto 14px;
|
||||
border: 1px solid rgb(255 255 255 / 7%);
|
||||
border-radius: 18px;
|
||||
background: rgb(0 255 136 / 8%);
|
||||
color: color-mix(in srgb, var(--v5-green) 74%, #ffffff);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-text {
|
||||
margin-bottom: 14px;
|
||||
color: #d7dedb;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-btn,
|
||||
.script-eval-v5-eval-btn,
|
||||
.script-eval-v5-export-btn,
|
||||
.script-eval-v5-action-btn,
|
||||
.script-eval-v5-retry-btn {
|
||||
font-family: inherit;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-btn,
|
||||
.script-eval-v5-eval-btn,
|
||||
.script-eval-v5-export-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-btn,
|
||||
.script-eval-v5-eval-btn {
|
||||
background: linear-gradient(180deg, #34ffa9 0%, var(--v5-green) 52%, #07cf73 100%);
|
||||
box-shadow: 0 12px 28px rgb(0 255 136 / 14%), inset 0 1px 0 rgb(255 255 255 / 36%);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-btn:hover,
|
||||
.script-eval-v5-eval-btn:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #55ffb8 0%, #10f58c 56%, var(--v5-green-dim) 100%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-hint {
|
||||
max-width: 300px;
|
||||
margin-inline: auto;
|
||||
color: #74837e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-done {
|
||||
min-height: 86px;
|
||||
border-color: var(--v5-line-strong);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(0 255 136 / 12%), rgb(0 255 136 / 5%)),
|
||||
rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.script-eval-v5-uf-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.script-eval-v5-uf-name {
|
||||
display: block;
|
||||
color: #e9fff5;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.script-eval-v5-uf-size {
|
||||
color: #7f918a;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.script-eval-v5-uf-re {
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--v5-radius-xs);
|
||||
color: #a2b0ab;
|
||||
transition: color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.script-eval-v5-uf-re:hover {
|
||||
background: rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.script-eval-v5-info-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.script-eval-v5-info-item {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(255 255 255 / 5%);
|
||||
border-radius: var(--v5-radius-sm);
|
||||
background: rgb(255 255 255 / 2.6%);
|
||||
}
|
||||
|
||||
.script-eval-v5-info-key {
|
||||
color: #84928d;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.script-eval-v5-info-val {
|
||||
color: #e5ebe8;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.script-eval-v5-info-tag {
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.script-eval-v5-info-empty,
|
||||
.script-eval-v5-history-empty {
|
||||
min-height: 70px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed rgb(255 255 255 / 9%);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background: rgb(255 255 255 / 2%);
|
||||
color: #75827e;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.script-eval-v5-history-list {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.script-eval-v5-history-item {
|
||||
border: 1px solid rgb(255 255 255 / 5%);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background: rgb(255 255 255 / 2.4%);
|
||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.script-eval-v5-history-item:hover {
|
||||
border-color: rgb(255 255 255 / 10%);
|
||||
background: rgb(255 255 255 / 4%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.script-eval-v5-history-item.is-active {
|
||||
border-color: var(--v5-line-strong);
|
||||
background: linear-gradient(90deg, rgb(0 255 136 / 10%), rgb(0 255 136 / 3%));
|
||||
box-shadow: inset 3px 0 0 var(--v5-green);
|
||||
}
|
||||
|
||||
.script-eval-v5-hi-name {
|
||||
color: #dce5e1;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.script-eval-v5-hi-date {
|
||||
color: #73817c;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.script-eval-v5-hi-score {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.script-eval-v5-hi-grade {
|
||||
color: #7d8985;
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-bottom {
|
||||
border-top-color: var(--v5-line);
|
||||
background: linear-gradient(180deg, rgb(19 22 22 / 72%), #111414);
|
||||
box-shadow: 0 -18px 34px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.script-eval-v5-eval-btn {
|
||||
min-height: 58px;
|
||||
border-radius: var(--v5-radius-md);
|
||||
font-size: 17px;
|
||||
font-weight: 850;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.script-eval-v5-eval-btn:disabled {
|
||||
opacity: 0.44;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.script-eval-v5-export-btn {
|
||||
min-height: 50px;
|
||||
border-color: rgb(255 255 255 / 9%);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background: rgb(255 255 255 / 3.5%);
|
||||
color: #aab8b2;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.script-eval-v5-export-btn:hover:not(:disabled) {
|
||||
border-color: var(--v5-line-strong);
|
||||
background: rgb(0 255 136 / 7%);
|
||||
color: #dcfff0;
|
||||
}
|
||||
|
||||
.script-eval-v5-right {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 2.4%), transparent 240px),
|
||||
#0d0f0f;
|
||||
}
|
||||
|
||||
.script-eval-v5-right-topbar {
|
||||
min-height: 52px;
|
||||
border-bottom-color: var(--v5-line);
|
||||
background: rgb(13 15 15 / 88%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.script-eval-v5-right-title {
|
||||
color: #87938f;
|
||||
font-size: 14px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.script-eval-v5-rt-green {
|
||||
color: #b7ffdc;
|
||||
}
|
||||
|
||||
.script-eval-v5-action-btn {
|
||||
min-height: 32px;
|
||||
border-color: rgb(255 255 255 / 9%);
|
||||
border-radius: var(--v5-radius-sm);
|
||||
background: rgb(255 255 255 / 3.8%);
|
||||
color: #a8b5b0;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.script-eval-v5-action-btn:hover {
|
||||
border-color: var(--v5-line-strong);
|
||||
background: rgb(0 255 136 / 7%);
|
||||
color: #dffff0;
|
||||
}
|
||||
|
||||
.script-eval-v5-right-content {
|
||||
padding: 20px 32px 42px;
|
||||
}
|
||||
|
||||
.script-eval-v5-illustration-hit {
|
||||
border: 1px solid rgb(255 255 255 / 7%);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4.5%), transparent),
|
||||
var(--v5-panel-3);
|
||||
box-shadow: var(--v5-shadow-soft);
|
||||
}
|
||||
|
||||
.script-eval-v5-illustration-hit:hover,
|
||||
.script-eval-v5-illustration-hit:focus-visible {
|
||||
border-color: var(--v5-line-strong);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(0 255 136 / 7%), transparent),
|
||||
var(--v5-panel-3);
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-card-icon {
|
||||
border: 1px solid var(--v5-line-strong);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(0 255 136 / 18%), rgb(0 255 136 / 8%)),
|
||||
#101714;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-card-title {
|
||||
color: #f4fbf8;
|
||||
font-size: 22px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-card-desc {
|
||||
color: #91a19b;
|
||||
}
|
||||
|
||||
.script-eval-v5-loading {
|
||||
min-width: min(520px, 92%);
|
||||
border: 1px solid rgb(255 255 255 / 7%);
|
||||
border-radius: var(--v5-radius-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent),
|
||||
var(--v5-panel-3);
|
||||
box-shadow: var(--v5-shadow-soft);
|
||||
}
|
||||
|
||||
.script-eval-v5-loading strong {
|
||||
color: #f4fbf8;
|
||||
}
|
||||
|
||||
.script-eval-v5-loading p {
|
||||
margin: 0;
|
||||
color: #8f9f99;
|
||||
}
|
||||
|
||||
.script-eval-v5-loading-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.script-eval-v5-loading-steps span {
|
||||
border: 1px solid rgb(0 255 136 / 18%);
|
||||
border-radius: 999px;
|
||||
background: rgb(0 255 136 / 7%);
|
||||
color: #b6ffdc;
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.script-eval-v5-error {
|
||||
border-color: rgb(244 178 46 / 28%);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background: rgb(244 178 46 / 8%);
|
||||
color: #f4c767;
|
||||
box-shadow: var(--v5-shadow-tight);
|
||||
}
|
||||
|
||||
.script-eval-v5-error span:first-child {
|
||||
color: #ffe0a1;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.script-eval-v5-retry-btn {
|
||||
border-color: rgb(244 178 46 / 32%);
|
||||
color: #ffd47a;
|
||||
}
|
||||
|
||||
.script-eval-report {
|
||||
--report-panel: #151918;
|
||||
--report-panel-2: #111514;
|
||||
--report-row: #181e1c;
|
||||
--report-border: rgb(255 255 255 / 8%);
|
||||
--report-muted: #93a29c;
|
||||
--report-dim: #67736f;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 3.2%), transparent 230px),
|
||||
var(--report-bg);
|
||||
}
|
||||
|
||||
.script-eval-report::before,
|
||||
.script-eval-report::after {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.script-eval-report--inside .script-eval-report__body {
|
||||
padding: 18px 32px 46px;
|
||||
}
|
||||
|
||||
.script-eval-report--inside .script-eval-report__hero {
|
||||
align-items: center;
|
||||
padding: 4px 0 22px;
|
||||
}
|
||||
|
||||
.script-eval-report__score-block {
|
||||
min-height: 184px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 18px 22px;
|
||||
border: 1px solid rgb(255 255 255 / 7%);
|
||||
border-radius: var(--v5-radius-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(0 255 136 / 9%), rgb(0 255 136 / 2%)),
|
||||
#101413;
|
||||
box-shadow: var(--v5-shadow-tight);
|
||||
}
|
||||
|
||||
.script-eval-report__score {
|
||||
text-shadow: 0 0 28px rgb(0 255 136 / 14%);
|
||||
}
|
||||
|
||||
.script-eval-report__score-total {
|
||||
color: #7e8b86;
|
||||
}
|
||||
|
||||
.script-eval-report__grade {
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.script-eval-report__beat {
|
||||
color: #a6b4ae;
|
||||
}
|
||||
|
||||
.script-eval-report__summary {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.script-eval-report__summary h1 {
|
||||
color: #f6fbf9;
|
||||
font-size: clamp(25px, 2vw, 32px);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.script-eval-report__summary p {
|
||||
color: #7d8b86;
|
||||
}
|
||||
|
||||
.script-eval-report__desc {
|
||||
max-width: 980px;
|
||||
color: #cbd5d1 !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.script-eval-report__chart-card,
|
||||
.script-eval-report__path-card {
|
||||
border-color: var(--report-border);
|
||||
border-radius: var(--v5-radius-lg);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 3.8%), transparent),
|
||||
var(--report-panel);
|
||||
box-shadow: var(--v5-shadow-tight);
|
||||
}
|
||||
|
||||
.script-eval-report__card-head {
|
||||
min-height: 50px;
|
||||
padding-inline: 18px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 5%);
|
||||
color: #b5c0bc;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.script-eval-report__legend {
|
||||
color: #7e8a86;
|
||||
}
|
||||
|
||||
.script-eval-report__chart {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.script-eval-report__chart-grid {
|
||||
gap: clamp(18px, 3vw, 52px);
|
||||
}
|
||||
|
||||
.script-eval-report__bar-col {
|
||||
cursor: pointer;
|
||||
transition: opacity 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.script-eval-report__bar-col:hover,
|
||||
.script-eval-report__bar-col:focus-visible {
|
||||
outline: none;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.script-eval-report__bar-col.is-dimmed {
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.script-eval-report__bar-fill {
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 18%), 0 10px 22px rgb(0 255 136 / 12%);
|
||||
}
|
||||
|
||||
.script-eval-report__bar-col:hover .script-eval-report__bar-fill,
|
||||
.script-eval-report__bar-col:focus-visible .script-eval-report__bar-fill {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.script-eval-report__bar-col strong {
|
||||
color: #eef5f2;
|
||||
}
|
||||
|
||||
.script-eval-report__bar-col > span {
|
||||
color: #74807c;
|
||||
}
|
||||
|
||||
.script-eval-report__chart-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 44px;
|
||||
margin: 0 18px 18px;
|
||||
border: 1px solid rgb(0 255 136 / 14%);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background: rgb(0 255 136 / 5%);
|
||||
color: #a9bbb4;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.script-eval-report__chart-note .anticon {
|
||||
flex-shrink: 0;
|
||||
color: var(--report-green);
|
||||
}
|
||||
|
||||
.script-eval-report__findings {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.script-eval-report__finding-group p {
|
||||
border: 1px solid rgb(255 255 255 / 5%);
|
||||
border-radius: var(--v5-radius-md);
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 3%), transparent), var(--report-row);
|
||||
color: #d7e0dc;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.script-eval-report__path-table th {
|
||||
color: #87938f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.script-eval-report__path-table td {
|
||||
color: #d8e1dd;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.script-eval-report__path-table tr:hover td {
|
||||
background: rgb(255 255 255 / 2.8%);
|
||||
}
|
||||
|
||||
.script-eval-v5-statusbar {
|
||||
height: 34px;
|
||||
border-top-color: var(--v5-line);
|
||||
background: rgb(18 21 21 / 94%);
|
||||
color: #7c8984;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.script-eval-v5.is-loading .script-eval-v5-status-dot {
|
||||
animation: v5-pulse 1.2s ease infinite;
|
||||
}
|
||||
|
||||
.script-eval-v5.is-complete .script-eval-v5-status-dot,
|
||||
.script-eval-v5.is-ready .script-eval-v5-status-dot {
|
||||
box-shadow: 0 0 16px rgb(0 255 136 / 34%);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.script-eval-report--inside .script-eval-report__body {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.script-eval-v5-page {
|
||||
padding-left: 0;
|
||||
padding-top: 74px;
|
||||
scroll-padding-top: 74px;
|
||||
}
|
||||
|
||||
.script-eval-v5-left {
|
||||
flex-basis: 320px;
|
||||
}
|
||||
|
||||
.script-eval-v5-right-content {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.script-eval-v5 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.script-eval-v5-page {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.script-eval-v5-left {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
max-height: none;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--v5-line);
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-section.is-fill {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.script-eval-v5-history-list {
|
||||
flex: 0 0 auto;
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-zone {
|
||||
min-height: 148px;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 15px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-text {
|
||||
margin-bottom: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-btn {
|
||||
padding: 9px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.script-eval-v5-upload-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.script-eval-v5-info-item {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.script-eval-v5-info-empty,
|
||||
.script-eval-v5-history-empty {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.script-eval-v5-lp-bottom {
|
||||
gap: 8px;
|
||||
padding: 12px 16px 14px;
|
||||
}
|
||||
|
||||
.script-eval-v5-eval-btn {
|
||||
min-height: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.script-eval-v5-export-btn {
|
||||
min-height: 42px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.script-eval-v5-right-topbar {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.script-eval-v5-right {
|
||||
flex: 0 0 auto;
|
||||
min-height: 560px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.script-eval-report--inside .script-eval-report__body {
|
||||
padding: 16px 16px 36px;
|
||||
}
|
||||
|
||||
.script-eval-report__score-block {
|
||||
min-height: 150px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.script-eval-report--inside .script-eval-report__score {
|
||||
font-size: clamp(62px, 22vw, 82px);
|
||||
}
|
||||
|
||||
.script-eval-report__score-total {
|
||||
padding-top: 24px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.script-eval-report__summary h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.script-eval-report__chart-note {
|
||||
margin-inline: 14px;
|
||||
}
|
||||
|
||||
.script-eval-report__finding-group p,
|
||||
.script-eval-report__path-table td {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3400,6 +3400,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 520px;
|
||||
max-height: 520px;
|
||||
padding: 18px 22px;
|
||||
border: none;
|
||||
outline: none;
|
||||
@@ -3409,6 +3410,7 @@
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.script-eval-v4-text-input::placeholder {
|
||||
@@ -4268,6 +4270,11 @@
|
||||
.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 {
|
||||
@@ -5370,3 +5377,559 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,3 +826,149 @@
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cookie-consent {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 1300;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
width: min(640px, calc(100vw - 36px));
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.28);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--fg-body);
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.cookie-consent strong,
|
||||
.cookie-consent p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cookie-consent p {
|
||||
margin-top: 5px;
|
||||
color: var(--fg-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.cookie-consent__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cookie-consent__actions a,
|
||||
.cookie-consent__actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cookie-consent__actions a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cookie-consent__actions button {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #07100b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.web-shell {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.web-topbar {
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.brand-lockup__tone,
|
||||
.profile-button span:not(.profile-button__avatar),
|
||||
.member-button__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-topbar__actions {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.member-button,
|
||||
.profile-button,
|
||||
.info-button {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.floating-nav {
|
||||
left: 50%;
|
||||
top: auto;
|
||||
bottom: max(10px, env(safe-area-inset-bottom));
|
||||
flex-direction: row;
|
||||
width: min(calc(100vw - 20px), 560px);
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
border-radius: 18px;
|
||||
transform: translateX(-50%);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.floating-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.floating-nav__item {
|
||||
flex: 0 0 44px;
|
||||
}
|
||||
|
||||
.floating-nav__label,
|
||||
.floating-nav__submenu,
|
||||
.floating-page-scroll-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-shell__page {
|
||||
padding-bottom: 78px;
|
||||
}
|
||||
|
||||
.info-popover,
|
||||
.profile-popover {
|
||||
right: -8px;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.brand-lockup__name {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.web-topbar__actions {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cookie-consent {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cookie-consent__actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
+2708
-18
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -14,7 +14,6 @@ export type WebViewKey =
|
||||
| "sizeTemplate"
|
||||
| "scriptTokens"
|
||||
| "tokenUsage"
|
||||
| "settings"
|
||||
| "imageWorkbench"
|
||||
| "resolutionUpscale"
|
||||
| "digitalHuman"
|
||||
@@ -26,7 +25,10 @@ export type WebViewKey =
|
||||
| "communityReview"
|
||||
| "communityCaseAdd"
|
||||
| "report"
|
||||
| "providerHealth";
|
||||
| "providerHealth"
|
||||
| "userAgreement"
|
||||
| "privacyPolicy"
|
||||
| "not-found";
|
||||
|
||||
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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;
|
||||
@@ -44,6 +45,8 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user