Compare commits

..

3 Commits

Author SHA1 Message Date
ludan 2b65206b84 feat: 电商克隆上传交互升级、视频模型选择器图标
【电商克隆 - 商品图上传交互重构】
- 新增上传预览大图区(clone-ai-upload-preview-wrap),点击缩略图可切换预览
- 选中缩略图增加 is-active 绿色边框高亮
- 预览区显示商品图编号 + 尺寸/比例/格式信息(formatProductImageSpec)
- 上传区到达 7 张上限时显示"已达上限"、阻止拖拽上传、输入框禁用
- 上传图片自动异步读取尺寸(width/height),无需等待上传完成即可展示
- 已上传素材区重构为列表头(标题+计数)+ 缩略图栈式布局
- 缩略图增加序号角标(1-7),删除按钮独立于缩略图下方
- selectedProductImageId 状态自动管理:删除/新增时自动切换到有效图片

【工作台 - 视频模型选择器图标】
- 新增 VIDEO_MODEL_ICON_URLS 映射(HappyHorse/Pixverse/Vidu/Wan/Kling)
- SelectChip 组件在 chipId=video-model 时显示模型品牌图标
- getVideoModelIconUrl 支持中英文模糊匹配

【样式】
- ecommerce.css: 预览区/素材栈/缩略图选中态/上限态完整样式
- dark-green.css: 主题层微调
2026-06-04 17:27:40 +08:00
ludan fb4011bf1f feat: 个人中心视觉重构、画布网点背景、剧本评分色调统一
【个人中心视觉重构】
- 列表卡片新增媒体预览缩略图(图片/视频/项目/资产),支持 image/video 两种媒体类型
- 新增 renderCardPreview 通用预览组件,自动识别视频格式并渲染 <video> 标签
- 新增 formatAssetType 工具函数,资产类型中文化(角色/场景/道具/视频/图像/素材)
- 媒体卡片采用固定高度网格布局(标题行 18px/正文 36px/元信息 18px),保证列表节奏一致
- 卡片预览区左上角显示类型标签徽章(品牌绿边框+半透明背景)
- 删除按钮增加 hover 红色反馈(边框/背景/文字渐变至红色)
- 积分/任务面板从底部区域移至侧边栏头像下方,减少滚动距离
- 新增 account-card 容器包裹积分/任务切换面板
- 侧边栏统计数据改为 3 列网格布局,每项增加独立圆角卡片样式
- 作品/项目/资产/社区发布四个 Tab 改为均分 4 列网格
- 分区标题增加品牌绿圆点前缀装饰
- 响应式断点:960px(侧边栏双列+内容区单列)、640px(全部单列+标签横向滚动)、420px(紧凑间距)

【画布网点背景】
- 移除 ReactFlow <Background> 组件,改用纯 CSS radial-gradient 圆点背景
- 通过 CSS 自定义属性(--canvas-bg-size/--canvas-bg-dot/--canvas-bg-x/--canvas-bg-y)实现缩放/平移时网点同步
- 网点颜色使用半透明灰蓝(rgba(148,163,184,0.34)),随画布缩放动态调整点间距与大小

【剧本评分色调统一】
- 变量 Token 体系重定义为电商同款暗色面板色调(--v5-bg: #0d0d0f, --v5-panel: #151719)
- 移除所有 box-shadow 和 depth 阴影,改用 inset 顶部光泽线
- 移除 backdrop-filter 毛玻璃效果,统一为纯色半透明背景
- hover 交互简化为边框+背景色变化,取消 transform 浮起动画
- 上传区移除 ::after 径向光晕伪元素
- 已上传态/选中态仅通过 border-color 和背景色微调区分
2026-06-04 13:16:38 +08:00
ludan b08a7918da feat: 剧本评分左侧面板滚动优化、电商克隆移动端适配、视觉细节精修
【剧本评分左侧面板滚动重构】
- 新增 script-eval-v5-left-main 滚动容器,上传区/AI信息/历史记录统一在容器内滚动
- 底部操作按钮(开始评测/导出报告)独立于滚动区外,始终可见可点击
- 历史评测列表增加 max-height 限制,超出区域内置滚动条
- 自定义窄滚动条(品牌绿半透明 thumb),保持视觉干净
- 短视口(≤760px/820px)压缩上传区和历史列表最小高度

【剧本评分视觉精修】
- 左侧面板增加渐变背景层次与分区微光分割线
- 上传区增加 ::after 伪元素径向光晕,hover 时品牌绿边框增强
- 已上传状态上传区增加绿色边框高亮(is-ready/is-complete)
- 底部操作栏背景层次加深,导出按钮 hover 增加绿色反馈
- 右侧面板增加底部径向渐变,上传引导卡标题提亮
- 顶部状态栏背景加深,模糊效果增强

【电商克隆移动端适配增强】
- 900px/620px/480px 三级断点增加顶部预留空间,避免与导航重叠
- Logo 区域定位从 sticky 改为 static,避免滚动时遮挡内容
- 设置面板在窄屏下调整内边距与边距

【Token 用量页精简】
- 移除指标卡片序号角标,保持卡片视觉简洁
2026-06-04 09:40:28 +08:00
36 changed files with 3228 additions and 2321 deletions
-291
View File
@@ -1,291 +0,0 @@
const fs = require("fs");
// ── Patch 1: context.js ──────────────────────────────────────
const ctxPath = "/opt/omniai-server/src/routes/context.js";
let ctx = fs.readFileSync(ctxPath, "utf8");
const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);";
const emailConsts = `
const EMAIL_PURPOSES = new Set(["register", "login", "reset"]);
const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10);
const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60);
const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`;
if (!ctx.includes("EMAIL_PURPOSES")) {
ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts);
console.log("[ctx] added EMAIL_PURPOSES");
}
const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}';
const emailFuncs = `
function hashEmailCode(email, code) {
const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret";
return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
}
async function sendEmailCode(email, code, purpose) {
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
if (provider === "smtp") {
const nodemailer = require("nodemailer");
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === "1",
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801";
await transporter.sendMail({
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: email,
subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002",
html: '<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px"><h2 style="color:#333">OmniAI \u90ae\u7bb1\u9a8c\u8bc1</h2><p style="font-size:16px;color:#555">\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a</p><p style="font-size:32px;font-weight:bold;letter-spacing:6px;color:#1677ff;margin:16px 0">' + code + '</p><p style="color:#888">\u7528\u9014\uff1a' + purposeText + '</p><p style="color:#888">\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f</p><hr style="border:none;border-top:1px solid #eee;margin:24px 0"><p style="color:#aaa;font-size:13px">\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002</p></div>',
});
return { provider: "smtp" };
}
console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)");
return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined };
}
async function consumeEmailCode(email, code, purpose) {
const { rows } = await pool.query(
"SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
[email, purpose]
);
const row = rows[0];
if (!row) return false;
if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false;
const expectedHash = hashEmailCode(email, String(code || "").trim());
if (row.code_hash !== expectedHash) {
await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]);
return false;
}
await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
return true;
}`;
if (!ctx.includes("hashEmailCode")) {
ctx = ctx.replace(afterConsume, afterConsume + emailFuncs);
console.log("[ctx] added email functions");
}
// Update exports
if (!ctx.includes("EMAIL_PURPOSES,")) {
ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,");
}
if (!ctx.includes("hashEmailCode,")) {
ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,");
}
fs.writeFileSync(ctxPath, ctx, "utf8");
console.log("[ctx] written");
// ── Patch 2: auth.js ─────────────────────────────────────────
const authPath = "/opt/omniai-server/src/routes/auth.js";
let auth = fs.readFileSync(authPath, "utf8");
// 2a. Add imports inside context.js destructuring
if (!auth.includes("hashEmailCode,")) {
auth = auth.replace(
'} = require("./context");',
' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");'
);
console.log("[auth] added imports");
}
// 2b. Insert new routes before module.exports
const newRoutes = `
// ============================================================
// Email verification routes
// ============================================================
router.post("/auth/email/send-code", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const purpose = String(req.body?.purpose || "register");
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
if (purpose === "register") {
const inviteOk = await ensureBetaInviteCode(req, res);
if (!inviteOk) return;
}
try {
const { rows: recentCodes } = await pool.query(
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
[email, purpose, EMAIL_CODE_COOLDOWN_SECONDS]
);
if (recentCodes.length > 0) {
return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
}
if (purpose === "register") {
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]);
if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" });
}
if (purpose === "login" || purpose === "reset") {
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" });
}
const code = generateSmsCode();
const codeHash = hashEmailCode(email, code);
await pool.query(
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)",
[email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES]
);
const sendResult = await sendEmailCode(email, code, purpose);
res.json({
success: true,
provider: sendResult.provider,
ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60,
cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS,
...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
});
} catch (error) {
console.error("[auth/email/send-code] failed", error);
res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" });
}
});
router.post("/auth/email/verify", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const code = String(req.body?.code || "").trim();
const purpose = String(req.body?.purpose || "register");
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
try {
const verified = await consumeEmailCode(email, code, purpose);
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
if (purpose === "register" || purpose === "login") {
await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]);
}
res.json({ success: true });
} catch (error) {
console.error("[auth/email/verify] failed", error);
res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" });
}
});
router.post("/auth/forgot-password", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
try {
const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
if (rows.length === 0) {
return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" });
}
const { rows: recentCodes } = await pool.query(
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
[email, EMAIL_CODE_COOLDOWN_SECONDS]
);
if (recentCodes.length > 0) {
return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
}
const code = generateSmsCode();
const codeHash = hashEmailCode(email, code);
await pool.query(
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)",
[email, codeHash, EMAIL_CODE_TTL_MINUTES]
);
await sendEmailCode(email, code, "reset");
res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" });
} catch (error) {
console.error("[auth/forgot-password] failed", error);
res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" });
}
});
router.post("/auth/reset-password", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const code = String(req.body?.code || "").trim();
const newPassword = String(req.body?.newPassword || "");
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
const passwordError = validatePassword(newPassword);
if (passwordError) return res.status(400).json({ error: passwordError });
try {
const verified = await consumeEmailCode(email, code, "reset");
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
const hash = await bcrypt.hash(newPassword, 10);
await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]);
res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" });
} catch (error) {
console.error("[auth/reset-password] failed", error);
res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" });
}
});
`;
if (!auth.includes("/auth/email/send-code")) {
const endMarker = "\n}\n\nmodule.exports = {";
auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {");
console.log("[auth] added new routes");
}
// 2c. Update register-email to require verification code
// Replace: router.post("/auth/register-email" ... without code check
// With: router.post("/auth/register-email" ... with code verification
const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const usernameInput = String(req.body?.username || "").trim();
const password = String(req.body?.password || "");
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const registrationInvite = await ensureRegistrationInvite(req, res);
if (!registrationInvite) return;
try {
const { rows: existingEmail }`;
const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
const email = normalizeEmail(req.body?.email);
const usernameInput = String(req.body?.username || "").trim();
const password = String(req.body?.password || "");
const code = String(req.body?.code || "").trim();
const emailError = validateEmail(email);
if (emailError) return res.status(400).json({ error: emailError });
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
const passwordError = validatePassword(password);
if (passwordError) return res.status(400).json({ error: passwordError });
const registrationInvite = await ensureRegistrationInvite(req, res);
if (!registrationInvite) return;
try {
const verified = await consumeEmailCode(email, code, "register");
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
const { rows: existingEmail }`;
if (auth.includes(oldRegisterEmail)) {
auth = auth.replace(oldRegisterEmail, newRegisterEmail);
console.log("[auth] updated register-email with verification");
} else {
console.log("[auth] WARNING: register-email pattern not found, skipping");
}
fs.writeFileSync(authPath, auth, "utf8");
console.log("[auth] written");
console.log("\nDone.");
+10 -33
View File
@@ -20,7 +20,6 @@ import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier"; import { initNotificationPermission } from "./utils/generationNotifier";
import PageTransition from "./components/PageTransition"; import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer"; import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
import { aiGenerationClient } from "./api/aiGenerationClient"; import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient"; import { keyServerClient } from "./api/keyServerClient";
import { notificationClient } from "./api/notificationClient"; import { notificationClient } from "./api/notificationClient";
@@ -33,10 +32,7 @@ import {
} from "./api/serverConnection"; } from "./api/serverConnection";
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError"; import { translateTaskError } from "./utils/translateTaskError";
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
import AppShell from "./components/AppShell"; import AppShell from "./components/AppShell";
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AgentPage = lazy(() => import("./features/agent/AgentPage"));
const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
@@ -59,6 +55,7 @@ const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/Wat
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
import { import {
@@ -105,6 +102,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"ecommerce", "ecommerce",
"scriptTokens", "scriptTokens",
"tokenUsage", "tokenUsage",
"settings",
"imageWorkbench", "imageWorkbench",
"resolutionUpscale", "resolutionUpscale",
"watermarkRemoval", "watermarkRemoval",
@@ -117,29 +115,22 @@ const VIEW_KEYS = new Set<WebViewKey>([
"communityCaseAdd", "communityCaseAdd",
"report", "report",
"providerHealth", "providerHealth",
"userAgreement",
"privacyPolicy",
"not-found",
]); ]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]); const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more"]);
function normalizeViewKey(rawView: string): WebViewKey { function normalizeViewKey(rawView: string): WebViewKey {
const normalized = const normalized =
rawView === "profile" || rawView === "auth" rawView === "profile" || rawView === "auth"
? "login" ? "login"
: rawView === "ecommerceHub" : rawView === "ecommerceHub"
? "ecommerce" ? "ecommerce"
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
? "userAgreement"
: rawView === "privacy" || rawView === "privacy-policy"
? "privacyPolicy"
: rawView === "community-review" : rawView === "community-review"
? "communityReview" ? "communityReview"
: rawView === "community-case-add" : rawView === "community-case-add"
? "communityCaseAdd" ? "communityCaseAdd"
: rawView; : rawView;
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home";
} }
function readViewFromHash(): WebViewKey { function readViewFromHash(): WebViewKey {
@@ -155,8 +146,7 @@ function isWorkspaceView(view: WebViewKey): boolean {
view !== "ecommerceHub" && view !== "ecommerceHub" &&
view !== "ecommerce" && view !== "ecommerce" &&
view !== "scriptTokens" && view !== "scriptTokens" &&
view !== "login" && view !== "login"
view !== "not-found"
); );
} }
@@ -328,11 +318,6 @@ function App() {
} }
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Recover background tasks on app start ──────────
useEffect(() => {
recoverAndResumeTasks();
}, []);
const navItems = useMemo<WebNavItem[]>( const navItems = useMemo<WebNavItem[]>(
() => [ () => [
{ key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> }, { key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
@@ -850,10 +835,6 @@ function App() {
setSession(nextSession); setSession(nextSession);
await hydrateAccountData(nextSession); await hydrateAccountData(nextSession);
if (nextSession.user.email && !nextSession.user.emailVerified) {
toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证");
}
const action = pendingAction; const action = pendingAction;
closeLoginPrompt(); closeLoginPrompt();
if (action) { if (action) {
@@ -1128,6 +1109,8 @@ function App() {
onSelectView={handleSetView} onSelectView={handleSetView}
/> />
); );
case "settings":
return <SettingsPage />;
case "imageWorkbench": case "imageWorkbench":
return ( return (
<ImageWorkbenchPage <ImageWorkbenchPage
@@ -1167,10 +1150,6 @@ function App() {
return <ReportPage />; return <ReportPage />;
case "providerHealth": case "providerHealth":
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />; return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
case "userAgreement":
return <CompliancePage kind="agreement" />;
case "privacyPolicy":
return <CompliancePage kind="privacy" />;
case "communityReview": case "communityReview":
return ( return (
<CommunityReviewPage <CommunityReviewPage
@@ -1199,6 +1178,7 @@ function App() {
/> />
); );
case "home": case "home":
default:
return ( return (
<HomePage <HomePage
onOpenGenerate={() => handleSetView("workbench")} onOpenGenerate={() => handleSetView("workbench")}
@@ -1210,9 +1190,6 @@ function App() {
onOpenImageTool={handleOpenImageWorkbenchTool} onOpenImageTool={handleOpenImageWorkbenchTool}
/> />
); );
case "not-found":
default:
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
} }
})(); })();
+40 -70
View File
@@ -1,7 +1,8 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"]; const TEXT_MODEL = "qwen-max";
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"]; const VISION_MODEL = "qwen3.7-plus";
const VISION_FALLBACK_MODEL = "qwen-vl-plus";
export interface AdVideoUserConfig { export interface AdVideoUserConfig {
platform: string; platform: string;
@@ -109,41 +110,27 @@ interface ChatMessage {
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const RETRY_BASE_MS = 2000; const RETRY_BASE_MS = 2000;
const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack) const CHAT_TIMEOUT_MS = 120_000; // 2 minutes per AI call
// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable
function isTransientError(err: unknown): boolean { function isTransientError(err: unknown): boolean {
if (!(err instanceof Error)) return false; if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase(); const msg = err.message.toLowerCase();
if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true; return /\b429\b/.test(msg) || msg.includes("signal timed out") || msg.includes("aborted") || msg.includes("timeout");
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> { async function retryOnTransient<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
let lastErr: unknown;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try { try {
return await fn(); return await fn();
} catch (err) { } catch (err) {
lastErr = err;
if (signal?.aborted) throw 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 (attempt === MAX_RETRIES) throw err;
if (!isTransientError(err)) throw err; if (!isTransientError(err)) throw err;
const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay)); await new Promise((r) => setTimeout(r, delay));
} }
} }
throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次"); throw new Error("unreachable");
} }
async function chat( async function chat(
@@ -151,45 +138,33 @@ async function chat(
userContent: string, userContent: string,
options?: { model?: string; signal?: AbortSignal }, options?: { model?: string; signal?: AbortSignal },
): Promise<string> { ): Promise<string> {
const candidateModels = options?.model ? [options.model] : TEXT_MODELS; return retryOnTransient(async () => {
let lastError: Error | null = null; const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
for (const model of candidateModels) { { role: "user", content: userContent },
try { ];
return await retryOnTransient(async () => { const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const messages: ChatMessage[] = [ const combinedSignal = options?.signal
{ role: "system", content: systemPrompt }, ? AbortSignal.any([options.signal, timeoutSignal])
{ role: "user", content: userContent }, : timeoutSignal;
]; const res = await fetch(buildApiUrl("ai/chat"), {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); method: "POST",
const combinedSignal = options?.signal headers: buildAuthHeaders(),
? AbortSignal.any([options.signal, timeoutSignal]) body: JSON.stringify({
: timeoutSignal; model: options?.model ?? TEXT_MODEL,
const res = await fetch(buildApiUrl("ai/chat"), { messages,
method: "POST", stream: false,
headers: buildAuthHeaders(), temperature: 0.4,
body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }), }),
signal: combinedSignal, signal: combinedSignal,
}); });
if (!res.ok) { if (!res.ok) throw new Error(`AI 调用失败 (${res.status})`);
const errBody = await res.text().catch(() => ""); const payload = await res.json();
throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); const content: string =
} payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
const payload = await res.json(); if (!content) throw new Error("模型未返回有效内容");
const content: string = return content;
payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; }, options?.signal);
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( async function visionChat(
@@ -207,8 +182,7 @@ async function visionChat(
{ role: "user", content }, { role: "user", content },
]; ];
let lastError: Error | null = null; for (const model of [VISION_MODEL, VISION_FALLBACK_MODEL]) {
for (const model of VISION_MODELS) {
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
const combinedSignal = signal const combinedSignal = signal
? AbortSignal.any([signal, timeoutSignal]) ? AbortSignal.any([signal, timeoutSignal])
@@ -223,8 +197,8 @@ async function visionChat(
}); });
if (!res.ok) { if (!res.ok) {
const errBody = await res.text().catch(() => ""); const errBody = await res.text().catch(() => "");
if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); if (model === VISION_MODEL && errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK");
throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); throw new Error(`图片理解调用失败 (${res.status})`);
} }
const payload = await res.json(); const payload = await res.json();
const result: string = const result: string =
@@ -234,16 +208,12 @@ async function visionChat(
}, signal); }, signal);
return out; return out;
} catch (err) { } catch (err) {
lastError = err instanceof Error ? err : new Error(String(err)); if (err instanceof Error && err.message === "IMAGE_FORMAT_FALLBACK") continue;
if (signal?.aborted) throw lastError; if (model === VISION_MODEL && err instanceof Error && err.message?.includes("图片理解调用失败")) continue;
// Continue trying next vision model on transient failures, image format errors, or upstream errors throw err;
if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue;
if (lastError.message.includes("图片理解调用失败")) continue;
if (isTransientError(lastError)) continue;
throw lastError;
} }
} }
throw lastError ?? new Error("图片理解调用失败,所有模型均不可用"); throw new Error("图片理解调用失败,所有模型均不可用");
} }
const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`;
-23
View File
@@ -63,17 +63,6 @@ export interface VideoGenInput {
style?: "speech" | "sing" | "performance" | string; 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 { export interface VideoSuperResolveInput {
projectId?: string; projectId?: string;
conversationId?: number; conversationId?: number;
@@ -301,18 +290,6 @@ export const aiGenerationClient = {
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed"); 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 }> { async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/super-resolve"), { const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
method: "POST", method: "POST",
+5 -85
View File
@@ -30,26 +30,9 @@ interface EmailAuthInput {
email: string; email: string;
password: string; password: string;
username?: string; username?: string;
code?: string;
betaCode?: 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 { interface PhoneAuthInput {
phone: string; phone: string;
code: string; code: string;
@@ -69,19 +52,6 @@ interface DeleteProjectOptions {
cleanupUserData?: boolean; 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 { export interface WechatLoginTicket {
configured: boolean; configured: boolean;
url?: string; url?: string;
@@ -654,21 +624,6 @@ 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> { function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record<string, unknown> {
const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
const projectId = workflow.id.trim(); const projectId = workflow.id.trim();
@@ -759,7 +714,6 @@ export const keyServerClient = {
email: input.email.trim(), email: input.email.trim(),
username: input.username?.trim() || undefined, username: input.username?.trim() || undefined,
password: input.password, password: input.password,
code: input.code?.trim() || undefined,
betaCode: input.betaCode?.trim() || undefined, betaCode: input.betaCode?.trim() || undefined,
}, },
}), }),
@@ -777,30 +731,6 @@ export const keyServerClient = {
body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined }, 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> { async loginPhone(input: PhoneAuthInput): Promise<WebUserSession> {
const session = normalizeLoginResult( const session = normalizeLoginResult(
await request<unknown>("/auth/login-phone", { await request<unknown>("/auth/login-phone", {
@@ -925,23 +855,13 @@ export const keyServerClient = {
return normalizeProjectContent(response, projectId); return normalizeProjectContent(response, projectId);
}, },
async getUsageSummary(): Promise<WebUsageSummary> { async getUsageSummary(): Promise<WebUsageSummary> {
const stored = readStoredSession(); return normalizeUsageSummary(await request<unknown>("/user/usage/summary"));
return normalizeUsageSummary(await request<unknown>("/user/usage/summary", { token: stored?.token }));
}, },
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> { async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
const stored = readStoredSession(); return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary"));
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary", { token: stored?.token }));
}, },
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> { async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
const stored = readStoredSession(); return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits"));
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> { async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
const stored = readStoredSession(); const stored = readStoredSession();
@@ -1009,8 +929,8 @@ export const keyServerClient = {
}); });
}, },
async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> { async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`); const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
return data; return data;
}, },
}; };
+16 -5
View File
@@ -1,5 +1,3 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
export interface ScriptEvalResult { export interface ScriptEvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
@@ -10,6 +8,8 @@ export interface ScriptEvalResult {
suggestions: string[]; 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 MODEL = "qwen3.7-max";
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
@@ -69,9 +69,16 @@ function extractJson(text: string): unknown {
} }
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const res = await fetch(buildApiUrl("ai/chat"), { if (!DASHSCOPE_API_KEY) {
throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY");
}
const res = await fetch(DASHSCOPE_ENDPOINT, {
method: "POST", method: "POST",
headers: buildAuthHeaders(), headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${DASHSCOPE_API_KEY}`,
},
body: JSON.stringify({ body: JSON.stringify({
model: MODEL, model: MODEL,
messages: [ messages: [
@@ -91,7 +98,11 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
} }
const payload = await res.json(); const payload = await res.json();
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; const content: string = payload?.choices?.[0]?.message?.content
?? payload?.result?.content
?? payload?.content
?? payload?.text
?? (typeof payload === "string" ? payload : "");
if (!content) throw new Error("模型未返回有效内容"); if (!content) throw new Error("模型未返回有效内容");
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { keyServerClient } from "../api/keyServerClient"; import { keyServerClient } from "../api/keyServerClient";
export interface ClientErrorItem { interface ClientErrorItem {
id: number; id: number;
message: string; message: string;
stack?: string; stack?: string;
+4 -7
View File
@@ -22,7 +22,6 @@ import NotificationCenter from "./NotificationCenter";
import { RechargeModal } from "./RechargeModal/RechargeModal"; import { RechargeModal } from "./RechargeModal/RechargeModal";
import { AnimatedPanel } from "./AnimatedPanel"; import { AnimatedPanel } from "./AnimatedPanel";
import AdminMonitor from "./AdminMonitor"; import AdminMonitor from "./AdminMonitor";
import CookieConsentBanner from "./CookieConsentBanner";
interface AppShellProps { interface AppShellProps {
activeView: WebViewKey; activeView: WebViewKey;
@@ -41,7 +40,6 @@ interface AppShellProps {
} }
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; 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 { function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100; const value = Math.max(0, cents) / 100;
@@ -346,8 +344,8 @@ function AppShell({
<dd>15155073618</dd> <dd>15155073618</dd>
</dl> </dl>
<div className="info-popover__links"> <div className="info-popover__links">
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}></a> <a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}></a> <a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}></a>
</div> </div>
</AnimatedPanel> </AnimatedPanel>
</div> </div>
@@ -358,7 +356,7 @@ function AppShell({
onClick={() => setRechargeOpen(true)} onClick={() => setRechargeOpen(true)}
> >
<WalletOutlined /> <WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span> {displayedBalanceLabel}
</button> </button>
<div className="profile-popover-anchor" ref={profileRef}> <div className="profile-popover-anchor" ref={profileRef}>
<button <button
@@ -473,9 +471,8 @@ function AppShell({
<div className="web-shell__page">{children}</div> <div className="web-shell__page">{children}</div>
</main> </main>
</div> </div>
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null} {session?.user.role === "admin" ? <AdminMonitor /> : null}
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> <RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
<CookieConsentBanner />
</div> </div>
); );
} }
-24
View File
@@ -1,24 +0,0 @@
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;
+1
View File
@@ -27,6 +27,7 @@ const NAV_ORDER: string[] = [
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
"agent", "agent",
"settings",
"login", "login",
"profile", "profile",
"report", "report",
@@ -1,10 +1,7 @@
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
import { useMemo, useState, type ReactNode } from "react"; import { useMemo, useState, type ReactNode } from "react";
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
import { toast } from "../toast/toastStore";
type RechargeAudience = "personal" | "enterprise"; type RechargeAudience = "personal" | "enterprise";
type PaymentMethod = "wechat" | "alipay" | "bank";
interface MembershipPlan { interface MembershipPlan {
id: string; id: string;
@@ -110,12 +107,6 @@ 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 { interface RechargeModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -125,43 +116,14 @@ interface RechargeModalProps {
export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) { export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) {
const [activeAudience, setActiveAudience] = useState<RechargeAudience>("personal"); const [activeAudience, setActiveAudience] = useState<RechargeAudience>("personal");
const [selectedPlanIds, setSelectedPlanIds] = useState<Record<RechargeAudience, string>>(defaultSelectedPlanIds); 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 visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
const selectedPlanId = selectedPlanIds[activeAudience]; const selectedPlanId = selectedPlanIds[activeAudience];
const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0];
const handlePlanSelect = (plan: MembershipPlan) => { const handlePlanSelect = (plan: MembershipPlan) => {
setSelectedPlanIds((current) => ({ setSelectedPlanIds((current) => ({
...current, ...current,
[plan.audience]: plan.id, [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; if (!open) return null;
@@ -262,44 +224,6 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr
))} ))}
</ol> </ol>
</footer> </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> </section>
</div> </div>
); );
+30 -40
View File
@@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
setContextMenu({ x: e.clientX, y: e.clientY, asset }); setContextMenu({ x: e.clientX, y: e.clientY, asset });
}, []); }, []);
const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => { const handleDeleteAsset = useCallback(async () => {
const target = asset || contextMenu?.asset; if (!contextMenu) return;
if (!target) return; const { asset } = contextMenu;
setContextMenu(null); setContextMenu(null);
try { try {
await assetClient.delete(target.id); await assetClient.delete(asset.id);
setServerAssets((prev) => prev.filter((a) => a.id !== target.id)); setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
setServerNotice(`已删除 ${target.name}`); setServerNotice(`已删除 ${asset.name}`);
} catch (err) { } catch (err) {
setServerNotice(err instanceof Error ? err.message : "删除失败"); setServerNotice(err instanceof Error ? err.message : "删除失败");
} }
@@ -287,42 +287,32 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
{visibleAssets.length ? ( {visibleAssets.length ? (
<div className="asset-grid asset-grid--desktop motion-stagger"> <div className="asset-grid asset-grid--desktop motion-stagger">
{visibleAssets.map((asset) => ( {visibleAssets.map((asset) => (
<div key={asset.id} className="asset-card-wrapper"> <button
<button key={asset.id}
type="button" type="button"
className="asset-card asset-card--desktop" className="asset-card asset-card--desktop"
onClick={() => setPreviewAsset(asset)} onClick={() => setPreviewAsset(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)} onContextMenu={(e) => handleContextMenu(e, asset)}
aria-label={`预览素材 ${asset.name}`} aria-label={`预览素材 ${asset.name}`}
> >
<div className={`asset-card__thumb ${asset.thumbClass}`}> <div className={`asset-card__thumb ${asset.thumbClass}`}>
{asset.imageUrl ? <OptimizedImage src={asset.imageUrl} alt={asset.name} /> : null} {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> </div>
<div className="asset-card__body"> <p className="asset-card__desc">{asset.description}</p>
<div className="asset-card__head"> <div className="asset-card__tags">
<strong>{asset.name}</strong> {asset.tags.slice(0, 2).map((tag) => (
<span className={`studio-status-bar__badge ${statusBadgeClass[asset.status]}`}> <span key={tag}>{tag}</span>
{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> </div>
<button </button>
type="button"
className="asset-card__delete"
title="删除素材"
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
aria-label={`删除 ${asset.name}`}
>
<DeleteOutlined />
</button>
</div>
))} ))}
</div> </div>
) : isLoading ? ( ) : isLoading ? (
+9 -13
View File
@@ -26,7 +26,6 @@
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
Background,
ReactFlow, ReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
@@ -3542,7 +3541,8 @@ function CanvasPage({
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{ style={{
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`, "--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`, "--canvas-bg-x": `${canvasViewport.x}px`,
"--canvas-bg-y": `${canvasViewport.y}px`, "--canvas-bg-y": `${canvasViewport.y}px`,
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined, cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
@@ -3717,9 +3717,6 @@ function CanvasPage({
<ReactFlow <ReactFlow
nodes={[]} nodes={[]}
edges={[]} edges={[]}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
minZoom={0.3} minZoom={0.3}
maxZoom={1.6} maxZoom={1.6}
panOnDrag={false} panOnDrag={false}
@@ -3730,9 +3727,7 @@ function CanvasPage({
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick} onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
> />
<Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}> <div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button> <button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}> <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
@@ -5534,11 +5529,6 @@ function CanvasPage({
role="menu" role="menu"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => {
if (pendingLinkPort) {
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
}
}}
> >
<div className="studio-canvas-add-node-menu__title"></div> <div className="studio-canvas-add-node-menu__title"></div>
<button <button
@@ -5550,6 +5540,8 @@ function CanvasPage({
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort; pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addTextNode(undefined, pos); addTextNode(undefined, pos);
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
setConnectionDropMenu(null); setConnectionDropMenu(null);
}} }}
> >
@@ -5565,6 +5557,8 @@ function CanvasPage({
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort; pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addImageNode("", "图片节点", pos); addImageNode("", "图片节点", pos);
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
setConnectionDropMenu(null); setConnectionDropMenu(null);
}} }}
> >
@@ -5580,6 +5574,8 @@ function CanvasPage({
const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); const pos = getTextNodePositionFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
pendingAutoConnectRef.current = connectionDropMenu.sourcePort; pendingAutoConnectRef.current = connectionDropMenu.sourcePort;
addVideoNode(pos); addVideoNode(pos);
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
setConnectionDropMenu(null); setConnectionDropMenu(null);
}} }}
> >
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { import {
CopyOutlined, CopyOutlined,
DownloadOutlined, DownloadOutlined,
@@ -15,7 +15,6 @@ import {
PLAN_STEPS_DISPLAY, PLAN_STEPS_DISPLAY,
type EcommerceVideoStage, type EcommerceVideoStage,
type EcommerceVideoSceneTask, type EcommerceVideoSceneTask,
type EcommerceVideoPlanProgress,
type EcommerceVideoPlanResult, type EcommerceVideoPlanResult,
type PlanStep, type PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
@@ -23,7 +22,6 @@ import type { AdVideoUserConfig } from "../../api/adVideoPlanClient";
import { ServerRequestError } from "../../api/serverConnection"; import { ServerRequestError } from "../../api/serverConnection";
import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions"; import { saveToolResultToLocal, addToolResultToAssetLibrary } from "../workbench/toolResultActions";
import { useAppStore } from "../../stores"; import { useAppStore } from "../../stores";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { import {
saveEcommerceVideoState, saveEcommerceVideoState,
loadEcommerceVideoState, loadEcommerceVideoState,
@@ -46,51 +44,10 @@ const ALL_STEPS: PlanStep[] = [
"creative", "storyboard", "prompts", "compliance", "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" { function mapResolutionToQuality(res: string): "720P" | "1080P" {
return res.includes("720") ? "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({ export default function EcommerceVideoWorkspace({
isAuthenticated, isAuthenticated,
productImageDataUrls, productImageDataUrls,
@@ -103,67 +60,38 @@ export default function EcommerceVideoWorkspace({
}: EcommerceVideoWorkspaceProps) { }: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle"); const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null); const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
const [planProgress, setPlanProgress] = useState<EcommerceVideoPlanProgress | null>(null);
const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]); const [scenes, setScenes] = useState<EcommerceVideoSceneTask[]>([]);
const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]); const [completedSteps, setCompletedSteps] = useState<PlanStep[]>([]);
const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]); const [sourceImageUrls, setSourceImageUrls] = useState<string[]>([]);
const [currentStep, setCurrentStep] = useState<PlanStep | null>(null); const [currentStep, setCurrentStep] = useState<PlanStep | null>(null);
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false }); const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null); const keepaliveRestoredRef = useRef(false);
const keepalivePollingStartedRef = useRef(false); 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 ───────────── // ── Keep-alive: restore saved state on mount ─────────────
useEffect(() => { useEffect(() => {
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return; if (keepaliveRestoredRef.current) return;
keepaliveRestoredFingerprintRef.current = inputFingerprint; keepaliveRestoredRef.current = true;
const saved = loadEcommerceVideoState(inputFingerprint); const saved = loadEcommerceVideoState();
if (!saved) return; if (!saved) return;
if (saved.stage === "idle" || saved.stage === "cancelled") return; if (saved.stage === "idle" || saved.stage === "cancelled") return;
// Restore completed / in-progress states — results persist across page switches // Restore completed / in-progress states — results persist across page switches
setStage(saved.stage); setStage(saved.stage);
setCompletedSteps(saved.completedSteps || []); setCompletedSteps(saved.completedSteps || []);
setPlanResult(saved.planResult); setPlanResult(saved.planResult);
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
setScenes(saved.scenes || []); setScenes(saved.scenes || []);
setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []); setSourceImageUrls(saved.sourceImageUrls || saved.planResult?.imageUrls || []);
}, [inputFingerprint]); }, []);
// ── Keep-alive: save state on changes ─────────────────── // ── Keep-alive: save state on changes ───────────────────
useEffect(() => { useEffect(() => {
if (stage === "idle" || stage === "cancelled") return; if (stage === "idle" || stage === "cancelled") return;
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls }); saveEcommerceVideoState({ stage, completedSteps, planResult, scenes, sourceImageUrls });
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]); }, [stage, completedSteps, planResult, 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 ────────── // ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => { useEffect(() => {
@@ -325,87 +253,38 @@ export default function EcommerceVideoWorkspace({
// ── Phase 1: Planning ────────────────────────────────────── // ── Phase 1: Planning ──────────────────────────────────────
const runPlanFlow = async (resume: EcommerceVideoPlanProgress | null) => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
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) => {
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");
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
} catch (err) {
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 () => { const handlePlan = async () => {
if (!isAuthenticated) { onRequestLogin?.(); return; } if (!isAuthenticated) { onRequestLogin?.(); return; }
if (!productImageDataUrls.length && !requirement.trim()) { if (!productImageDataUrls.length && !requirement.trim()) {
setError("请先上传产品图片或填写商品说明"); return; setError("请先上传产品图片或填写商品说明"); return;
} }
await runPlanFlow(null); abortControllerRef.current?.abort();
}; const controller = new AbortController();
abortControllerRef.current = controller;
const handleResumePlan = async () => { setStage("planning"); setError(null);
if (!isAuthenticated) { onRequestLogin?.(); return; } setCompletedSteps([]); setCurrentStep(null);
if (!planProgress) { void handlePlan(); return; } setPlanResult(null); setScenes([]); setSourceImageUrls([]);
await runPlanFlow(planProgress); 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 }); },
signal: controller.signal,
},
);
const builtScenes = buildSceneTasks(result);
setPlanResult(result);
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 });
} catch (err) {
if ((err as Error).name === "AbortError") return;
setError(err instanceof Error ? err.message : "策划失败");
setStage("idle");
} finally { setCurrentStep(null); }
}; };
// ── Phase 2: Image generation per scene ────────────────────── // ── Phase 2: Image generation per scene ──────────────────────
@@ -421,34 +300,19 @@ export default function EcommerceVideoWorkspace({
const persistScenes = (next: EcommerceVideoSceneTask[]) => { const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next; currentScenes = next;
setScenes(next); setScenes(next);
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls }); saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
}; };
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry for (const scene of currentScenes) {
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
if (!scenesToProcess.length) { setStage("imaged"); return; }
for (const scene of scenesToProcess) {
if (renderAbortRef.current.current) break; if (renderAbortRef.current.current) break;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try { try {
await renderSceneImage( await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio }, { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
{ {
onSceneImageSubmitted: (id, taskId) => { onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
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)), onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneImageCompleted: (id, url) => { onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
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)),
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, renderAbortRef.current,
); );
@@ -460,14 +324,15 @@ export default function EcommerceVideoWorkspace({
const allHaveImages = currentScenes.every((s) => s.imageUrl); const allHaveImages = currentScenes.every((s) => s.imageUrl);
const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const; const finalStage = allHaveImages ? "imaged" as const : "partial_failed" as const;
setStage(finalStage); setStage(finalStage);
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
}; };
// ── Phase 3: Video rendering from generated images ────────── // ── Phase 3: Video rendering from generated images ──────────
const handleRenderVideos = async () => { const handleRenderVideos = async () => {
if (!scenes.length) return; if (!scenes.length) return;
if (!scenes.some((s) => s.imageUrl)) { setError("请先生成分镜图片"); return; } const firstImage = scenes[0]?.imageUrl;
if (!firstImage) { setError("请先生成分镜图片"); return; }
setStage("rendering"); setError(null); setStage("rendering"); setError(null);
renderAbortRef.current = { current: false }; renderAbortRef.current = { current: false };
const quality = mapResolutionToQuality(resolution); const quality = mapResolutionToQuality(resolution);
@@ -475,35 +340,20 @@ export default function EcommerceVideoWorkspace({
const persistScenes = (next: EcommerceVideoSceneTask[]) => { const persistScenes = (next: EcommerceVideoSceneTask[]) => {
currentScenes = next; currentScenes = next;
setScenes(next); setScenes(next);
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls }); saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
}; };
// Only render scenes that haven't completed yet — preserves successful videos on partial retry for (const scene of currentScenes) {
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 (renderAbortRef.current.current) break;
if (!scene.imageUrl) continue; if (!scene.imageUrl) continue;
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending" } : s));
try { try {
await renderScene( await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality }, { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
{ {
onSceneSubmitted: (id, taskId) => { onSceneSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
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)), onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
onSceneCompleted: (id, url) => { onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
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)),
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, renderAbortRef.current,
); );
@@ -519,7 +369,7 @@ export default function EcommerceVideoWorkspace({
const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const; const finalStage = allDone ? (hasFailed ? "partial_failed" as const : "completed" as const) : "rendering" as const;
setScenes(currentScenes); setScenes(currentScenes);
setStage(finalStage); setStage(finalStage);
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls }); saveEcommerceVideoState({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
}; };
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); }; const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
@@ -574,32 +424,26 @@ export default function EcommerceVideoWorkspace({
<div className="ecom-video-flowbar__actions"> <div className="ecom-video-flowbar__actions">
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null} {error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleResumePlan()} title={`从「${failedStep ? PLAN_STEP_LABELS[failedStep] : "已中断处"}」继续策划`}>
<ReloadOutlined />
</button>
) : null}
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? ( {stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action" <button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}> onClick={() => void handlePlan()} title="一键策划">
<PlayCircleOutlined /> <PlayCircleOutlined />
</button> </button>
) : null} ) : null}
{stage === "planned" || stage === "imaged" ? ( {stage === "planned" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}> onClick={() => void handleGenerateImages()} title="生成图片">
{stage === "imaged" ? <ReloadOutlined /> : <SendOutlined />} <SendOutlined />
</button> </button>
) : null} ) : null}
{stage === "imaged" || (stage === "partial_failed" && imagedScenes.length > 0) ? ( {stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleRenderVideos()} title={stage === "partial_failed" ? "重新生成失败的视频" : "生成视频"}> onClick={() => void handleRenderVideos()} title="生成视频">
<SendOutlined /> <SendOutlined />
</button> </button>
) : null} ) : null}
{stage === "planning" ? ( {stage === "planning" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> {currentStep ? PLAN_STEP_LABELS[currentStep] : "策划中"}</span> <span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
) : null} ) : null}
{stage === "imaging" ? ( {stage === "imaging" ? (
<span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span> <span className="ecom-video-flowbar__stage-label"><LoadingOutlined /> </span>
@@ -1,7 +1,6 @@
import type { import type {
EcommerceVideoStage, EcommerceVideoStage,
EcommerceVideoSceneTask, EcommerceVideoSceneTask,
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult, EcommerceVideoPlanResult,
PlanStep, PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
@@ -9,22 +8,18 @@ import type {
const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace"; const KEEPALIVE_KEY = "omniai:ecommerce-video-workspace";
interface EcommerceVideoKeepalive { interface EcommerceVideoKeepalive {
inputFingerprint: string;
stage: EcommerceVideoStage; stage: EcommerceVideoStage;
completedSteps: PlanStep[]; completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null; planResult: EcommerceVideoPlanResult | null;
planProgress?: EcommerceVideoPlanProgress | null;
scenes: EcommerceVideoSceneTask[]; scenes: EcommerceVideoSceneTask[];
sourceImageUrls: string[]; sourceImageUrls: string[];
savedAt: number; savedAt: number;
} }
export function saveEcommerceVideoState(state: { export function saveEcommerceVideoState(state: {
inputFingerprint: string;
stage: EcommerceVideoStage; stage: EcommerceVideoStage;
completedSteps: PlanStep[]; completedSteps: PlanStep[];
planResult: EcommerceVideoPlanResult | null; planResult: EcommerceVideoPlanResult | null;
planProgress?: EcommerceVideoPlanProgress | null;
scenes: EcommerceVideoSceneTask[]; scenes: EcommerceVideoSceneTask[];
sourceImageUrls?: string[]; sourceImageUrls?: string[];
}): void { }): void {
@@ -40,7 +35,7 @@ export function saveEcommerceVideoState(state: {
} }
} }
export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVideoKeepalive | null { export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
try { try {
const raw = window.localStorage.getItem(KEEPALIVE_KEY); const raw = window.localStorage.getItem(KEEPALIVE_KEY);
if (!raw) return null; if (!raw) return null;
@@ -50,7 +45,6 @@ export function loadEcommerceVideoState(inputFingerprint: string): EcommerceVide
clearEcommerceVideoState(); clearEcommerceVideoState();
return null; return null;
} }
if (parsed.inputFingerprint !== inputFingerprint) return null;
return parsed; return parsed;
} catch { } catch {
return null; return null;
+40 -105
View File
@@ -11,9 +11,7 @@ import {
import { aiGenerationClient } from "../../api/aiGenerationClient"; import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription"; import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel"; import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
import type { import type {
EcommerceVideoPlanProgress,
EcommerceVideoPlanResult, EcommerceVideoPlanResult,
EcommerceVideoSceneTask, EcommerceVideoSceneTask,
PlanStep, PlanStep,
@@ -23,129 +21,66 @@ export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
onImagesUploaded?: (urls: string[]) => void; onImagesUploaded?: (urls: string[]) => void;
onUploadRejected?: (messages: string[]) => void;
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
signal?: AbortSignal; 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( export async function runVideoPlan(
imageDataUrls: string[], imageDataUrls: string[],
manualText: string, manualText: string,
config: AdVideoUserConfig, config: AdVideoUserConfig,
callbacks: PlanCallbacks, callbacks: PlanCallbacks,
): Promise<EcommerceVideoPlanResult> { ): Promise<EcommerceVideoPlanResult> {
const { onStepStart, onStepDone, signal, resumeFrom = {} } = callbacks; const { onStepStart, onStepDone, signal } = callbacks;
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress });
// ── Step: upload ────────────────────────────────────── onStepStart("upload");
if (!progress.imageUrls?.length) { const imageUrls: string[] = [];
onStepStart("upload"); const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
const imageUrls: string[] = []; for (const srcUrl of imageDataUrls) {
const rejected: string[] = []; try {
for (const srcUrl of imageDataUrls) { const resp = await fetch(srcUrl);
try { const rawBlob = await resp.blob();
const resp = await fetch(srcUrl); const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
const rawBlob = await resp.blob(); const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const mimeType = normalizeEcommerceImageMime(rawBlob.type); const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); imageUrls.push(result.url);
const dataUrl = await new Promise<string>((resolve, reject) => { } catch {
const reader = new FileReader(); // skip images that fail to upload
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);
// ── Step: analyze ───────────────────────────────────── onStepStart("analyze");
if (progress.imageDescription === undefined) { const imageDesc = await analyzeProductImages(imageUrls, signal);
onStepStart("analyze"); onStepDone("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
onStepDone("analyze");
emit();
}
// ── Step: summary ───────────────────────────────────── onStepStart("summary");
if (!progress.summary) { const summary = await buildProductSummary(imageDesc, manualText, signal);
onStepStart("summary"); onStepDone("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
onStepDone("summary");
emit();
}
// ── Step: selling ───────────────────────────────────── onStepStart("selling");
if (!progress.selling) { const selling = await extractSellingPoints(summary, signal);
onStepStart("selling"); onStepDone("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
onStepDone("selling");
emit();
}
// ── Step: creative ──────────────────────────────────── onStepStart("creative");
if (!progress.creatives?.length) { const creatives = await generateCreativeOptions(selling, config, signal);
onStepStart("creative"); if (!creatives.length) throw new Error("未能生成有效的广告创意");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal); onStepDone("creative");
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
emit();
}
// ── Step: storyboard ────────────────────────────────── onStepStart("storyboard");
if (!progress.storyboard) { const storyboard = await generateStoryboard(creatives[0], summary, config, signal);
onStepStart("storyboard"); onStepDone("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
onStepDone("storyboard");
emit();
}
// ── Step: prompts ───────────────────────────────────── onStepStart("prompts");
if (!progress.videoPrompts) { const videoPrompts = await generateVideoPrompts(storyboard, summary, signal);
onStepStart("prompts"); onStepDone("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
onStepDone("prompts");
emit();
}
// ── Step: compliance ────────────────────────────────── onStepStart("compliance");
if (!progress.compliance) { const compliance = await checkCompliance(summary, selling, storyboard, signal);
onStepStart("compliance"); onStepDone("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
onStepDone("compliance");
emit();
}
return { return { imageUrls, summary, selling, creatives, storyboard, videoPrompts, compliance };
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 { export interface RenderSceneImageInput {
@@ -36,7 +36,6 @@ export interface EcommerceVideoSceneTask {
export interface EcommerceVideoPlanResult { export interface EcommerceVideoPlanResult {
imageUrls: string[]; imageUrls: string[];
imageDescription?: string;
summary: ProductSummary; summary: ProductSummary;
selling: SellingPointResult; selling: SellingPointResult;
creatives: CreativeOption[]; creatives: CreativeOption[];
@@ -45,19 +44,6 @@ export interface EcommerceVideoPlanResult {
compliance: ComplianceCheck; 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 { export interface EcommerceVideoDelivery {
planResult: EcommerceVideoPlanResult | null; planResult: EcommerceVideoPlanResult | null;
scenes: EcommerceVideoSceneTask[]; scenes: EcommerceVideoSceneTask[];
+137 -198
View File
@@ -5,10 +5,13 @@ import {
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined, LockOutlined,
MailOutlined, MailOutlined,
MobileOutlined, MobileOutlined,
PhoneOutlined, PhoneOutlined,
PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SafetyOutlined, SafetyOutlined,
ShareAltOutlined, ShareAltOutlined,
@@ -179,6 +182,19 @@ function formatAssetStatus(status: string | undefined): string {
return status || "资产"; return status || "资产";
} }
function formatAssetType(type: SavedAssetItem["type"]): string {
const labels: Record<string, string> = {
character: "角色",
scene: "场景",
prop: "道具",
video: "视频",
image: "图像",
asset: "资产",
other: "素材",
};
return labels[type] || "素材";
}
function ProfilePage({ function ProfilePage({
session, session,
usage, usage,
@@ -214,14 +230,6 @@ function ProfilePage({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0); const [smsCooldown, setSmsCooldown] = useState(0);
const [isSendingSms, setIsSendingSms] = useState(false); 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 [activePanel, setActivePanel] = useState<ProfilePanel>("works");
const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits"); const [accountPanel, setAccountPanel] = useState<AccountPanel>("credits");
@@ -304,70 +312,6 @@ function ProfilePage({
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [smsCooldown]); }, [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 () => { const handleSendSms = async () => {
if (smsCooldown > 0 || !phone.trim() || isSendingSms) return; if (smsCooldown > 0 || !phone.trim() || isSendingSms) return;
if (mode === "register" && !betaCode.trim()) { if (mode === "register" && !betaCode.trim()) {
@@ -412,10 +356,6 @@ function ProfilePage({
if (!value.trim()) return "请输入验证码"; if (!value.trim()) return "请输入验证码";
if (value.length !== 6) return "验证码为 6 位数字"; if (value.length !== 6) return "验证码为 6 位数字";
return ""; return "";
case "emailCode":
if (!value.trim()) return "请输入邮箱验证码";
if (value.length !== 6) return "验证码为 6 位数字";
return "";
default: default:
return ""; return "";
} }
@@ -455,10 +395,6 @@ function ProfilePage({
if (emailErr) errors.email = emailErr; if (emailErr) errors.email = emailErr;
const pwErr = validateField("password", password); const pwErr = validateField("password", password);
if (pwErr) errors.password = pwErr; if (pwErr) errors.password = pwErr;
if (mode === "register") {
const codeErr = validateField("emailCode", emailCode);
if (codeErr) errors.emailCode = codeErr;
}
} else { } else {
const userErr = validateField("username", username); const userErr = validateField("username", username);
if (userErr) errors.username = userErr; if (userErr) errors.username = userErr;
@@ -485,7 +421,7 @@ function ProfilePage({
const nextSession = const nextSession =
mode === "login" mode === "login"
? await keyServerClient.loginEmail({ email, password }) ? await keyServerClient.loginEmail({ email, password })
: await keyServerClient.registerEmail({ email, password, code: emailCode, username: username.trim() || undefined, betaCode }); : await keyServerClient.registerEmail({ email, password, username: username.trim() || undefined, betaCode });
await onAuthComplete?.(nextSession); await onAuthComplete?.(nextSession);
} else if (mode === "login") { } else if (mode === "login") {
await onLogin(username.trim(), password); await onLogin(username.trim(), password);
@@ -607,20 +543,48 @@ function ProfilePage({
</div> </div>
); );
const renderCardPreview = (
url: string | null | undefined,
type: "image" | "video" | "project" | "asset",
label: string,
) => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov)(\?|#|$)/i.test(mediaUrl);
const placeholderIcon =
type === "video" ? <PlayCircleOutlined /> : type === "project" ? <FolderOpenOutlined /> : <FileImageOutlined />;
return (
<div className={`profile-page__list-card-preview${mediaUrl ? " has-media" : ""}`} aria-hidden="true">
{mediaUrl ? (
isVideoPreview ? (
<video src={mediaUrl} muted playsInline preload="metadata" />
) : (
<img src={mediaUrl} alt="" loading="lazy" />
)
) : (
<span className="profile-page__list-card-placeholder">{placeholderIcon}</span>
)}
<span className="profile-page__media-badge">{label}</span>
</div>
);
};
const renderActivePanel = () => { const renderActivePanel = () => {
if (activePanel === "works") { if (activePanel === "works") {
return visibleWorks.length ? ( return visibleWorks.length ? (
<div className="profile-page__list-grid motion-stagger"> <div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => ( {visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card"> <article key={task.id} className="profile-page__list-card profile-page__media-card">
<div className="profile-page__list-card-head"> {renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<strong>{task.title}</strong> <div className="profile-page__list-card-body">
<span>{formatTaskType(task.type)}</span> <div className="profile-page__list-card-head">
</div> <strong>{task.title}</strong>
<p>{task.prompt}</p> </div>
<div className="profile-page__list-card-meta"> <p>{task.prompt}</p>
<span>{formatTaskStatus(task.status)}</span> <div className="profile-page__list-card-meta">
<span>{formatProfileDate(task.createdAt)}</span> <span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -634,25 +598,27 @@ function ProfilePage({
return projects.length ? ( return projects.length ? (
<div className="profile-page__list-grid motion-stagger"> <div className="profile-page__list-grid motion-stagger">
{projects.map((project) => ( {projects.map((project) => (
<article key={project.id} className="profile-page__list-card"> <article key={project.id} className="profile-page__list-card profile-page__media-card">
<div className="profile-page__list-card-head"> {renderCardPreview(project.thumbnailUrl, "project", "项目")}
<strong>{project.name}</strong> <div className="profile-page__list-card-body">
<span>{formatProfileDate(project.updatedAt)}</span> <div className="profile-page__list-card-head">
{onDeleteProject ? ( <strong>{project.name}</strong>
<button {onDeleteProject ? (
type="button" <button
className="profile-page__delete-project" type="button"
aria-label={`删除项目 ${project.name}`} className="profile-page__delete-project"
onClick={() => onDeleteProject(project)} aria-label={`删除项目 ${project.name}`}
> onClick={() => onDeleteProject(project)}
<DeleteOutlined /> >
</button> <DeleteOutlined />
) : null} </button>
</div> ) : null}
<p>{project.description || "最近更新的项目"}</p> </div>
<div className="profile-page__list-card-meta"> <p>{project.description || "最近更新的项目"}</p>
<span>{project.storyboardCount} </span> <div className="profile-page__list-card-meta">
<span>{project.imageCount} / {project.videoCount} </span> <span>{project.storyboardCount} </span>
<span>{formatProfileDate(project.updatedAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -666,15 +632,18 @@ function ProfilePage({
return savedAssets.length ? ( return savedAssets.length ? (
<div className="profile-page__list-grid"> <div className="profile-page__list-grid">
{savedAssets.map((asset) => ( {savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card"> <article key={asset.id} className="profile-page__list-card profile-page__media-card">
<div className="profile-page__list-card-head"> {renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<strong>{asset.name}</strong> <div className="profile-page__list-card-body">
<span>{formatAssetStatus(asset.status)}</span> <div className="profile-page__list-card-head">
</div> <strong>{asset.name}</strong>
<p>{asset.description}</p> <span>{formatAssetStatus(asset.status)}</span>
<div className="profile-page__list-card-meta"> </div>
<span>{asset.type}</span> <p>{asset.description}</p>
<span>{formatProfileDate(asset.updatedAt)}</span> <div className="profile-page__list-card-meta">
<span>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -788,6 +757,50 @@ function ProfilePage({
</div> </div>
</div> </div>
<div className="profile-page__account-card">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<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 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>
</div>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan"> <button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined /> <ShareAltOutlined />
{packageLabel} {packageLabel}
@@ -835,52 +848,6 @@ function ProfilePage({
</span> </span>
{renderActivePanel()} {renderActivePanel()}
</div> </div>
<div className="profile-page__section">
<div className="profile-page__list-bar">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<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 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>
</div>
</main> </main>
</div> </div>
</section> </section>
@@ -998,31 +965,7 @@ function ProfilePage({
</label> </label>
) : null} ) : null}
{showForgotPassword ? ( {authTab === "password" ? (
<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" : ""}`}> <label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
<span> <span>
@@ -1058,13 +1001,13 @@ function ProfilePage({
</label> </label>
{mode === "login" ? ( {mode === "login" ? (
<div className="auth-page__forgot"> <div className="auth-page__forgot">
<button type="button" onClick={() => { setShowForgotPassword(true); setForgotStep("email"); }}></button> <button type="button"></button>
</div> </div>
) : null} ) : null}
</> </>
) : null} ) : null}
{!showForgotPassword && authTab === "email" ? ( {authTab === "email" ? (
<> <>
{mode === "register" ? ( {mode === "register" ? (
<label className="auth-page__field"> <label className="auth-page__field">
@@ -1120,7 +1063,7 @@ function ProfilePage({
</> </>
) : null} ) : null}
{!showForgotPassword && authTab === "phone" ? ( {authTab === "phone" ? (
<> <>
<label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}> <label className={`auth-page__field${fieldErrors.phone ? " auth-page__field--error" : ""}`}>
<span> <span>
@@ -1171,11 +1114,9 @@ function ProfilePage({
</> </>
) : null} ) : null}
{!showForgotPassword ? ( {notice ? <p className="auth-page__notice">{notice}</p> : null}
<>
{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" ? "登录" : "注册"} {isSubmitting ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button> </button>
@@ -1195,8 +1136,6 @@ function ProfilePage({
<MobileOutlined /> <MobileOutlined />
</button> </button>
</div> </div>
</>
) : null}
</form> </form>
</div> </div>
</aside> </aside>
+99 -243
View File
@@ -36,8 +36,6 @@ interface HistoryEntry {
timestamp: number; timestamp: number;
score: number; score: number;
grade: string; grade: string;
script?: string;
result?: EvalResult;
} }
function getGrade(score: number): string { function getGrade(score: number): string {
@@ -59,8 +57,6 @@ const TEXT_FILE_EXTENSIONS = [
".fountain", ".fountain",
".fdx", ".fdx",
".rtf", ".rtf",
".docx",
".doc",
".csv", ".csv",
".tsv", ".tsv",
".json", ".json",
@@ -106,7 +102,7 @@ const TEXT_FILE_EXTENSIONS = [
] as const; ] as const;
const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS); const TEXT_FILE_EXTENSION_SET = new Set<string>(TEXT_FILE_EXTENSIONS);
const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(","); const TEXT_FILE_ACCEPT = TEXT_FILE_EXTENSIONS.join(",");
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等"; const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
function loadHistory(): HistoryEntry[] { function loadHistory(): HistoryEntry[] {
try { try {
@@ -175,117 +171,10 @@ function normalizeUploadedText(raw: string, ext: string): string {
return raw; 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 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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"")).join("");
}).filter(Boolean).join("\n").trim();
}
return "";
}
function formatFileSize(size: number): string { function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`; if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`; return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
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 paragraphs: string[] = [];
let currentLine = "";
for (const match of textMatches) {
const content = match.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"");
currentLine += content;
}
// Try to find paragraph breaks
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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"")).join("");
}).filter(Boolean).join("\n").trim();
}
return currentLine.trim();
=======
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`;
>>>>>>> origin/master
} }
const SCORE_DIMENSIONS: ScoreDimension[] = [ const SCORE_DIMENSIONS: ScoreDimension[] = [
@@ -342,7 +231,6 @@ function ScriptTokensPage() {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [activeDim, setActiveDim] = useState<number | null>(null); const [activeDim, setActiveDim] = useState<number | null>(null);
const [animatedScore, setAnimatedScore] = useState(0); const [animatedScore, setAnimatedScore] = useState(0);
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory); const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const scoreFrameRef = useRef<number | null>(null); const scoreFrameRef = useRef<number | null>(null);
@@ -372,23 +260,7 @@ function ScriptTokensPage() {
const ext = getFileExtension(file.name); const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext); const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size }); setUploadedFile({ name: file.name, size: file.size });
if (ext === ".docx") { if (readable) {
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); const text = normalizeUploadedText(await decodeTextFile(file), ext);
setScript(text); setScript(text);
} else { } else {
@@ -414,8 +286,6 @@ function ScriptTokensPage() {
timestamp: Date.now(), timestamp: Date.now(),
score: aiResult.totalScore, score: aiResult.totalScore,
grade: g, grade: g,
script,
result: aiResult,
}; };
const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort( const updated = [entry, ...loadHistory().filter((h) => h.name !== entry.name || h.score !== entry.score)].sort(
(a, b) => b.timestamp - a.timestamp, (a, b) => b.timestamp - a.timestamp,
@@ -428,20 +298,6 @@ function ScriptTokensPage() {
setLoading(false); 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 = () => { const handleReset = () => {
setScript(""); setScript("");
setResult(null); setResult(null);
@@ -506,111 +362,111 @@ function ScriptTokensPage() {
<div className="script-eval-v5-page"> <div className="script-eval-v5-page">
{/* Left Panel */} {/* Left Panel */}
<aside className="script-eval-v5-left"> <aside className="script-eval-v5-left">
<div className="script-eval-v5-lp-section"> <div className="script-eval-v5-left-main">
<div className="script-eval-v5-lp-label"></div> <div className="script-eval-v5-lp-section">
<div <div className="script-eval-v5-lp-label"></div>
className="script-eval-v5-upload-zone" <div
role="button" className="script-eval-v5-upload-zone"
tabIndex={0} role="button"
onClick={() => fileInputRef.current?.click()} tabIndex={0}
onKeyDown={uploadKeyDown} onClick={() => fileInputRef.current?.click()}
> onKeyDown={uploadKeyDown}
{uploadedFile ? ( >
<div className="script-eval-v5-upload-done is-show"> {uploadedFile ? (
<CheckCircleFilled /> <div className="script-eval-v5-upload-done is-show">
<span className="script-eval-v5-uf-meta"> <CheckCircleFilled />
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span> <span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span> <span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
</span> <span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}> </span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
</span>
</div> </span>
) : ( </div>
<> ) : (
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div> <>
<div className="script-eval-v5-upload-text"></div> <div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}> <div className="script-eval-v5-upload-text"></div>
<UploadOutlined /> <button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
</button> <UploadOutlined />
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div> </button>
</> <div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
)} </>
)}
</div>
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
</div> </div>
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
</div>
<div className="script-eval-v5-lp-section"> <div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label">AI </div> <div className="script-eval-v5-lp-label">AI </div>
<div className="script-eval-v5-info-grid"> <div className="script-eval-v5-info-grid">
{!result ? ( {!result ? (
<div className="script-eval-v5-info-empty"></div> <div className="script-eval-v5-info-empty"></div>
) : ( ) : (
<> <>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val"><span className="script-eval-v5-info-tag">{result.totalScore} · {grade}</span></span> <span className="script-eval-v5-info-val"><span className="script-eval-v5-info-tag">{result.totalScore} · {grade}</span></span>
</div> </div>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{script.length} </span> <span className="script-eval-v5-info-val">{script.length} </span>
</div> </div>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span> <span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span>
</div> </div>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{beatPct}%</span> <span className="script-eval-v5-info-val">{beatPct}%</span>
</div> </div>
</> </>
)} )}
</div>
</div> </div>
</div>
<div className="script-eval-v5-lp-section is-fill"> <div className="script-eval-v5-lp-section is-fill">
<div className="script-eval-v5-lp-label"></div> <div className="script-eval-v5-lp-label"></div>
<div className="script-eval-v5-history-list"> <div className="script-eval-v5-history-list">
{!session ? ( {!session ? (
<div className="script-eval-v5-history-empty"></div> <div className="script-eval-v5-history-empty"></div>
) : history.length === 0 ? ( ) : history.length === 0 ? (
<div className="script-eval-v5-history-empty"></div> <div className="script-eval-v5-history-empty"></div>
) : ( ) : (
history.map((item, i) => ( history.map((item, i) => (
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`} <div key={i} className={`script-eval-v5-history-item${i === 0 ? " is-active" : ""}`}>
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0} <div className="script-eval-v5-hi-left">
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}> <div className="script-eval-v5-hi-name">{item.name}</div>
<div className="script-eval-v5-hi-left"> <div className="script-eval-v5-hi-date">{item.date}</div>
<div className="script-eval-v5-hi-name">{item.name}</div> <div className="script-eval-v5-hi-bar">
<div className="script-eval-v5-hi-date">{item.date}</div> <div className="script-eval-v5-hi-bar-fill" style={{ width: `${Math.min(92, (item.score / 100) * 100)}%` }} />
<div className="script-eval-v5-hi-bar"> </div>
<div className="script-eval-v5-hi-bar-fill" style={{ width: `${Math.min(92, (item.score / 100) * 100)}%` }} /> </div>
<div className="script-eval-v5-hi-right">
<div className={`script-eval-v5-hi-score${item.score >= 90 ? " is-green" : ""}`}>{item.score}</div>
<div className="script-eval-v5-hi-grade">{item.grade}</div>
</div> </div>
</div> </div>
<div className="script-eval-v5-hi-right"> ))
<div className={`script-eval-v5-hi-score${item.score >= 90 ? " is-green" : ""}`}>{item.score}</div> )}
<div className="script-eval-v5-hi-grade">{item.grade}</div> </div>
</div>
</div>
))
)}
</div> </div>
</div>
<div className="script-eval-v5-lp-bottom"> <div className="script-eval-v5-lp-bottom">
<button <button
type="button" type="button"
className="script-eval-v5-eval-btn" className="script-eval-v5-eval-btn"
disabled={loading || !hasContent} disabled={loading || !hasContent}
onClick={() => void handleEvaluate()} onClick={() => void handleEvaluate()}
> >
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />} {loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
<span>{loading ? "评测中..." : "开始评测"}</span> <span>{loading ? "评测中..." : "开始评测"}</span>
</button> </button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}> <button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
<DownloadOutlined /> <DownloadOutlined />
<span></span> <span></span>
</button> </button>
</div>
</div> </div>
</aside> </aside>
+10 -5
View File
@@ -6,6 +6,7 @@ import {
LineChartOutlined, LineChartOutlined,
ReloadOutlined, ReloadOutlined,
RightOutlined, RightOutlined,
SettingOutlined,
TeamOutlined, TeamOutlined,
UserOutlined, UserOutlined,
WarningOutlined, WarningOutlined,
@@ -148,10 +149,10 @@ function TokenUsagePage({
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise"); const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
const refreshEnterpriseUsage = useCallback(async () => { const refreshEnterpriseUsage = useCallback(async () => {
if (!session) return;
const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage; const loader = isEnterpriseAdmin ? loadEnterpriseUsage : loadPersonalUsage;
if (!loader) { if (!loader) {
setEnterpriseUsage(null); setEnterpriseUsage(null);
setEnterpriseUsageError(null);
return; return;
} }
setEnterpriseUsageLoading(true); setEnterpriseUsageLoading(true);
@@ -160,11 +161,11 @@ function TokenUsagePage({
setEnterpriseUsage(await loader()); setEnterpriseUsage(await loader());
} catch (error) { } catch (error) {
setEnterpriseUsage(null); setEnterpriseUsage(null);
setEnterpriseUsageError(error instanceof Error ? error.message : "加载失败"); setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
} finally { } finally {
setEnterpriseUsageLoading(false); setEnterpriseUsageLoading(false);
} }
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]); }, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
useEffect(() => { useEffect(() => {
void refreshEnterpriseUsage(); void refreshEnterpriseUsage();
@@ -261,19 +262,23 @@ function TokenUsagePage({
<UserOutlined /> <UserOutlined />
</button> </button>
<button type="button" className="is-primary" onClick={() => onSelectView?.("settings")}>
<SettingOutlined />
</button>
</header> </header>
{isLowBalance ? ( {isLowBalance ? (
<div className="management-balance-alert" role="alert"> <div className="management-balance-alert" role="alert">
<WarningOutlined /> <WarningOutlined />
<span> {formatCredits(availableBalanceCents)}</span> <span> {formatCredits(availableBalanceCents)}</span>
<button type="button" onClick={() => onSelectView?.("settings")}></button>
</div> </div>
) : null} ) : null}
<section className="management-metric-cards" aria-label="关键指标"> <section className="management-metric-cards" aria-label="关键指标">
{metricCards.map((card, index) => ( {metricCards.map((card) => (
<article key={card.key} className={`management-metric-card is-${card.tone}`}> <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> <span className="management-metric-card__label">{card.label}</span>
<strong className="management-metric-card__value">{card.value}</strong> <strong className="management-metric-card__value">{card.value}</strong>
<span className="management-metric-card__hint">{card.hint}</span> <span className="management-metric-card__hint">{card.hint}</span>
-4
View File
@@ -41,7 +41,6 @@ import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUp
import { assetClient } from "../../api/assetClient"; import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient"; import { communityClient } from "../../api/communityClient";
import { RechargeModal } from "../../components/RechargeModal/RechargeModal"; import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
import { useGenerationTasks } from "../../hooks/useGenerationTasks";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient"; import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient"; import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
@@ -240,7 +239,6 @@ function WorkbenchPage({
const scrollActionsHideTimerRef = useRef<number | null>(null); const scrollActionsHideTimerRef = useRef<number | null>(null);
const shouldFollowNewMessagesRef = useRef(true); const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true); const pendingScrollToLatestRef = useRef(true);
const genTracker = useGenerationTasks({ sourceView: "workbench" });
const renderedMessageIdsRef = useRef<string[]>([]); const renderedMessageIdsRef = useRef<string[]>([]);
const hasHandledInitialMessagesRef = useRef(false); const hasHandledInitialMessagesRef = useRef(false);
@@ -1882,7 +1880,6 @@ function WorkbenchPage({
referenceUrls: refUrls.length ? refUrls : undefined, referenceUrls: refUrls.length ? refUrls : undefined,
}); });
taskId = result.taskId; taskId = result.taskId;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
} else { } else {
let requestModel = resolveVideoRequestModel({ let requestModel = resolveVideoRequestModel({
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL, model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
@@ -1902,7 +1899,6 @@ function WorkbenchPage({
hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"), hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"),
}); });
taskId = result.taskId; taskId = result.taskId;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "video", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
} }
onRefreshUsage?.(); onRefreshUsage?.();
@@ -3,6 +3,24 @@ import type { ReactNode } from "react";
import type { WorkbenchOption, WorkbenchFieldGroup } from "./workbenchConstants"; import type { WorkbenchOption, WorkbenchFieldGroup } from "./workbenchConstants";
import { getRatioOptionClassName, getSettingsGridColumnsClassName } from "./workbenchReferenceUtils"; import { getRatioOptionClassName, getSettingsGridColumnsClassName } from "./workbenchReferenceUtils";
const VIDEO_MODEL_ICON_URLS = {
happyHorse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/HappyHorse.svg",
pixverse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/Pixverse.svg",
vidu: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/viduQ3.svg",
wanxiang: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/wan.svg",
kling: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/kling.svg",
} as const;
function getVideoModelIconUrl(option: WorkbenchOption): string | null {
const text = `${option.value} ${option.label}`.toLowerCase();
if (text.includes("happyhorse")) return VIDEO_MODEL_ICON_URLS.happyHorse;
if (text.includes("pixverse")) return VIDEO_MODEL_ICON_URLS.pixverse;
if (text.includes("vidu")) return VIDEO_MODEL_ICON_URLS.vidu;
if (text.includes("wan") || text.includes("万相")) return VIDEO_MODEL_ICON_URLS.wanxiang;
if (text.includes("kling") || text.includes("可灵")) return VIDEO_MODEL_ICON_URLS.kling;
return null;
}
export function SelectChip({ export function SelectChip({
chipId, chipId,
value, value,
@@ -56,6 +74,7 @@ export function SelectChip({
> >
{options.map((option, index) => { {options.map((option, index) => {
const active = option.value === value; const active = option.value === value;
const iconUrl = chipId === "video-model" ? getVideoModelIconUrl(option) : null;
return ( return (
<button <button
key={option.value} key={option.value}
@@ -71,6 +90,11 @@ export function SelectChip({
> >
<span className="ai-workbench-select-chip__option-label"> <span className="ai-workbench-select-chip__option-label">
<span className="ai-workbench-select-chip__option-dot" aria-hidden="true" /> <span className="ai-workbench-select-chip__option-dot" aria-hidden="true" />
{iconUrl ? (
<span className="ai-workbench-select-chip__option-icon" aria-hidden="true">
<img src={iconUrl} alt="" loading="lazy" />
</span>
) : null}
<span className="ai-workbench-select-chip__option-copy"> <span className="ai-workbench-select-chip__option-copy">
<span className="ai-workbench-select-chip__option-title"> <span className="ai-workbench-select-chip__option-title">
<span>{option.label}</span> <span>{option.label}</span>
-2
View File
@@ -3,8 +3,6 @@ export { useSessionStore } from './useSessionStore';
export { useProjectStore } from './useProjectStore'; export { useProjectStore } from './useProjectStore';
export { useTaskStore } from './useTaskStore'; export { useTaskStore } from './useTaskStore';
export { useAppStore } from './useAppStore'; export { useAppStore } from './useAppStore';
export { useGenerationStore } from './useGenerationStore';
export type { GenerationQueueItem, QueueItemStatus } from './useGenerationStore';
// Type exports // Type exports
export type { PendingAction } from './useSessionStore'; export type { PendingAction } from './useSessionStore';
-102
View File
@@ -365,113 +365,11 @@
line-height: 1.55; 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) { @media (max-width: 980px) {
.recharge-modal__grid[data-audience="personal"], .recharge-modal__grid[data-audience="personal"],
.recharge-modal__grid[data-audience="enterprise"] { .recharge-modal__grid[data-audience="enterprise"] {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.recharge-modal__payment-methods {
grid-template-columns: 1fr;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
-1
View File
@@ -29,7 +29,6 @@
@import "./pages/compliance.css"; @import "./pages/compliance.css";
@import "./pages/provider-health.css"; @import "./pages/provider-health.css";
@import "./pages/legacy-pages.css"; @import "./pages/legacy-pages.css";
@import "./pages/not-found.css";
@import "./components/recharge-modal.css"; @import "./components/recharge-modal.css";
@import "./components/dropzone.css"; @import "./components/dropzone.css";
@import "./components/skeleton.css"; @import "./components/skeleton.css";
-34
View File
@@ -189,40 +189,6 @@
cursor: zoom-in; 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) { @media (max-width: 720px) {
.asset-preview-modal { .asset-preview-modal {
padding: 14px; padding: 14px;
+1 -107
View File
@@ -725,110 +725,9 @@
font-size: 42px; 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) { @media (max-width: 900px) {
.community-review-page__inner, .community-review-page__inner,
.report-page__inner, .report-page__inner {
.compliance-page__inner {
width: min(100% - 28px, 720px); width: min(100% - 28px, 720px);
padding-top: 24px; padding-top: 24px;
} }
@@ -887,9 +786,4 @@
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
.compliance-hero,
.compliance-section {
grid-template-columns: 1fr;
}
} }
+328 -53
View File
@@ -1157,6 +1157,25 @@
background: #202c28; background: #202c28;
} }
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone.is-full {
cursor: default;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone.is-full strong {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: #aeb8c4;
box-shadow: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone.is-full:hover {
border-color: rgba(255, 255, 255, 0.16);
background:
radial-gradient(circle at 50% 0%, rgba(var(--ecm-accent-rgb), 0.09), transparent 58%),
var(--ecm-inset);
transform: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:active { .product-clone-page[data-tool="clone"] .clone-ai-upload-zone:active {
transform: scale(0.98); transform: scale(0.98);
} }
@@ -1274,6 +1293,27 @@
transform: scale(0.92); transform: scale(0.92);
} }
.product-clone-page[data-tool="clone"] .clone-ai-card,
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone,
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-files {
position: relative;
overflow: visible;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone {
z-index: 8;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-uploaded-file:hover),
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-uploaded-file:focus-within) {
z-index: 90;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file:hover,
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file:focus-within {
z-index: 95;
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-section { .product-clone-page[data-tool="clone"] .clone-ai-settings-section {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -2809,26 +2849,6 @@
line-height: 1.6; 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 { .product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
display: grid; display: grid;
grid-template-columns: minmax(210px, 300px) 54px minmax(330px, 560px); grid-template-columns: minmax(210px, 300px) 54px minmax(330px, 560px);
@@ -3116,7 +3136,7 @@
.uploaded-image-zoom { .uploaded-image-zoom {
position: absolute; position: absolute;
z-index: 70; z-index: 220;
left: 50%; left: 50%;
bottom: calc(100% + 10px); bottom: calc(100% + 10px);
display: block; display: block;
@@ -3137,6 +3157,18 @@
visibility: hidden; visibility: hidden;
} }
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file .uploaded-image-zoom {
left: 0;
bottom: calc(100% + 12px);
width: min(240px, 58vw);
transform: translate(0, 8px) scale(0.96);
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file:hover .uploaded-image-zoom,
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file:focus-within .uploaded-image-zoom {
transform: translate(0, 0) scale(1);
}
.uploaded-image-zoom img { .uploaded-image-zoom img {
display: block; display: block;
width: 100%; width: 100%;
@@ -3159,6 +3191,233 @@
visibility: visible; visibility: visible;
} }
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-upload-preview-wrap) {
align-content: start;
justify-items: stretch;
gap: 10px;
min-height: 0;
padding: 12px;
text-align: left;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-upload-preview-wrap) .clone-ai-upload-main {
grid-template-columns: 34px minmax(0, 1fr) auto;
width: 100%;
align-items: center;
justify-items: start;
gap: 4px 9px;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-upload-preview-wrap) .clone-ai-upload-icon {
grid-row: span 2;
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 16px;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-upload-preview-wrap) .clone-ai-upload-title {
min-width: 0;
color: #c9d2dd;
font-size: 12px;
line-height: 1.2;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-upload-preview-wrap) strong {
grid-row: span 2;
min-width: 96px;
height: 34px;
padding: 0 12px;
font-size: 12px;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone:has(.clone-ai-upload-preview-wrap) .clone-ai-upload-hint {
grid-column: 2;
min-width: 0;
font-size: 10px;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview-wrap {
display: grid;
gap: 7px;
min-width: 0;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview {
position: relative;
display: grid;
width: 100%;
min-height: 126px;
overflow: hidden;
border: 1px solid rgba(var(--ecm-accent-rgb), 0.2);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 48%),
rgba(8, 10, 12, 0.56);
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview img {
display: block;
width: 100%;
height: 126px;
object-fit: contain;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview__meta {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 2px;
color: #eef2f6;
text-align: left;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview__meta span {
display: grid;
min-width: 0;
gap: 1px;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview__meta b,
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview__meta em {
display: block;
overflow: hidden;
max-width: 260px;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview__meta b {
color: var(--ecm-accent);
font-size: 10px;
font-style: normal;
font-weight: 900;
}
.product-clone-page[data-tool="clone"] .clone-ai-upload-preview__meta em {
color: #aeb8c4;
font-size: 10px;
font-style: normal;
font-weight: 700;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-files {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 0;
padding-top: 2px;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-stack {
display: grid;
gap: 7px;
min-width: 0;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #768292;
font-size: 10px;
font-weight: 900;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-head span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-head b {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid rgba(var(--ecm-accent-rgb), 0.18);
border-radius: 999px;
background: rgba(var(--ecm-accent-rgb), 0.07);
color: var(--ecm-accent);
font-size: 10px;
line-height: 1.4;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file {
width: 46px;
height: 46px;
overflow: hidden;
border-color: rgba(255, 255, 255, 0.12);
border-radius: 9px;
background: rgba(255, 255, 255, 0.035);
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file > button:not(.clone-ai-uploaded-file__thumb) {
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: rgba(8, 10, 12, 0.82);
color: #fff;
font-size: 9px;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file.is-active {
border-color: rgba(var(--ecm-accent-rgb), 0.86);
box-shadow: 0 0 0 2px rgba(var(--ecm-accent-rgb), 0.12);
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file .clone-ai-uploaded-file__thumb {
position: static;
inset: auto;
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
color: inherit;
cursor: pointer;
font-size: inherit;
opacity: 1;
transform: none;
transition: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file .clone-ai-uploaded-file__thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file__thumb span {
position: absolute;
left: 3px;
bottom: 3px;
min-width: 14px;
height: 14px;
border-radius: 999px;
background: rgba(8, 10, 12, 0.76);
color: #eef2f6;
font-size: 9px;
font-weight: 900;
line-height: 14px;
text-align: center;
}
.product-clone-page[data-tool="clone"] .clone-ai-uploaded-file:hover {
border-color: rgba(var(--ecm-accent-rgb), 0.62);
transform: translateY(-1px);
}
@keyframes image-mention-menu-rise { @keyframes image-mention-menu-rise {
from { from {
opacity: 0; opacity: 0;
@@ -7954,34 +8213,6 @@
.clone-ai-adwizard__risk.is-high { color: #ff4d4f; } .clone-ai-adwizard__risk.is-high { color: #ff4d4f; }
.clone-ai-adwizard__issues { margin: 0; padding-left: 16px; font-size: 12px; display: flex; flex-direction: column; gap: 4px; } .clone-ai-adwizard__issues { margin: 0; padding-left: 16px; font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
.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. */ /* Ecommerce generation page SaaS polish: visual-only refinement for the product creation workspace. */
.product-clone-page { .product-clone-page {
--ecm-page: #0e1012; --ecm-page: #0e1012;
@@ -8056,9 +8287,8 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-logo { .product-clone-page[data-tool="clone"] .clone-ai-logo {
position: sticky; position: static;
top: 0; z-index: auto;
z-index: 3;
margin: -18px -18px 2px; margin: -18px -18px 2px;
padding: 16px 18px 14px; padding: 16px 18px 14px;
border-bottom-color: var(--ecm-line); border-bottom-color: var(--ecm-line);
@@ -8206,6 +8436,13 @@
box-shadow: 0 10px 28px rgba(var(--ecm-accent-rgb), 0.18); box-shadow: 0 10px 28px rgba(var(--ecm-accent-rgb), 0.18);
} }
.product-clone-page[data-tool="clone"] .clone-ai-upload-zone.is-full strong {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: #aeb8c4;
box-shadow: none;
}
.product-clone-page[data-tool="clone"] :is(.clone-ai-generate:hover:not(:disabled), .clone-ai-send-button:hover:not(:disabled)) { .product-clone-page[data-tool="clone"] :is(.clone-ai-generate:hover:not(:disabled), .clone-ai-send-button:hover:not(:disabled)) {
filter: brightness(1.03); filter: brightness(1.03);
transform: translateY(-1px); transform: translateY(-1px);
@@ -8561,7 +8798,7 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-logo { .product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: -14px -14px 0; margin: 0;
padding: 14px 54px 12px 14px; padding: 14px 54px 12px 14px;
} }
@@ -8840,4 +9077,42 @@
padding-top: 14px; padding-top: 14px;
} }
} }
/* Mobile clone header alignment: keep the tool title in normal flow, but attach it to the top nav rhythm. */
@media (max-width: 900px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding-top: 0;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -18px 2px;
}
}
@media (max-width: 620px) {
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding: 0 14px 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -14px 0;
}
}
@media (max-width: 480px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
} }
+2 -4
View File
@@ -362,8 +362,7 @@
left: 50%; left: 50%;
display: grid; display: grid;
width: clamp(214px, 17vw, 286px); width: clamp(214px, 17vw, 286px);
height: auto; height: clamp(122px, 9.8vw, 162px);
aspect-ratio: 16 / 9;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
border: 0; border: 0;
@@ -435,8 +434,7 @@
.omni-home__carousel-card.is-active { .omni-home__carousel-card.is-active {
width: clamp(390px, 37vw, 620px); width: clamp(390px, 37vw, 620px);
height: auto; height: clamp(220px, 20.8vw, 350px);
aspect-ratio: 16 / 9;
border-radius: clamp(16px, 1.8vw, 24px); border-radius: clamp(16px, 1.8vw, 24px);
box-shadow: box-shadow:
0 18px 40px rgb(0 0 0 / 26%), 0 18px 40px rgb(0 0 0 / 26%),
-56
View File
@@ -1,56 +0,0 @@
.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);
}
+508
View File
@@ -3421,3 +3421,511 @@
font-size: 13px; font-size: 13px;
} }
} }
/* Script review left panel overflow guard: keep actions available while history remains scrollable. */
.script-eval-v5-left {
overflow: hidden;
}
.script-eval-v5-left-main {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(0 255 136 / 35%) transparent;
}
.script-eval-v5-left-main::-webkit-scrollbar,
.script-eval-v5-history-list::-webkit-scrollbar {
width: 6px;
}
.script-eval-v5-left-main::-webkit-scrollbar-track,
.script-eval-v5-history-list::-webkit-scrollbar-track {
background: transparent;
}
.script-eval-v5-left-main::-webkit-scrollbar-thumb,
.script-eval-v5-history-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(0 255 136 / 28%);
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
flex: 0 0 auto;
min-height: 210px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 128px;
max-height: clamp(160px, 28vh, 300px);
overflow-y: auto;
}
.script-eval-v5-lp-bottom {
position: static;
z-index: auto;
flex-shrink: 0;
margin-top: 0;
}
@media (max-height: 820px) and (min-width: 901px) {
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
flex-basis: auto;
min-height: 190px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 118px;
max-height: clamp(142px, 23vh, 220px);
}
}
@media (max-width: 900px) {
.script-eval-v5-left-main {
overscroll-behavior: contain;
}
}
@media (max-width: 680px) {
.script-eval-v5-left {
overflow: visible;
}
.script-eval-v5-left-main {
flex: 0 0 auto;
overflow: visible;
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
min-height: 224px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 132px;
max-height: min(260px, 42vh);
}
.script-eval-v5-history-empty {
min-height: 118px;
}
}
/* Final commercial polish for the script scoring workspace. */
.script-eval-v5 {
background:
radial-gradient(circle at 12% 0%, rgb(0 255 136 / 5%), transparent 28%),
linear-gradient(180deg, #0d1010 0%, #090b0b 100%);
}
.script-eval-v5-page {
background:
linear-gradient(90deg, rgb(0 255 136 / 4%), transparent 24%),
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent 180px);
}
.script-eval-v5-left {
background:
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 180px),
linear-gradient(90deg, rgb(0 255 136 / 4%), transparent 32%),
var(--v5-panel);
}
.script-eval-v5-left-main {
scroll-padding-block: 18px;
}
.script-eval-v5-left-main .script-eval-v5-lp-section {
flex-shrink: 0;
padding-inline: 22px;
background:
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent 80px);
}
.script-eval-v5-left-main .script-eval-v5-lp-section + .script-eval-v5-lp-section {
box-shadow: inset 0 1px 0 rgb(255 255 255 / 2.5%);
}
.script-eval-v5-lp-label {
color: #91a09b;
}
.script-eval-v5-upload-zone {
display: grid;
place-items: center;
overflow: hidden;
isolation: isolate;
}
.script-eval-v5-upload-zone::after {
content: "";
position: absolute;
inset: 1px;
z-index: -1;
border-radius: inherit;
background:
radial-gradient(circle at 50% 18%, rgb(0 255 136 / 11%), transparent 38%),
linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 60%);
opacity: 0.78;
pointer-events: none;
}
.script-eval-v5-upload-zone:focus-visible {
outline: 2px solid rgb(0 255 136 / 42%);
outline-offset: 3px;
}
.script-eval-v5.is-ready .script-eval-v5-upload-zone,
.script-eval-v5.is-complete .script-eval-v5-upload-zone {
border-color: rgb(0 255 136 / 28%);
background:
linear-gradient(180deg, rgb(0 255 136 / 8%), rgb(255 255 255 / 2.5%)),
rgb(255 255 255 / 2.8%);
}
.script-eval-v5-upload-done {
width: min(100%, 320px);
padding: 14px 14px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
}
.script-eval-v5-info-grid {
display: grid;
grid-template-columns: 1fr;
}
.script-eval-v5-info-item {
min-height: 42px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
}
.script-eval-v5-info-empty,
.script-eval-v5-history-empty {
color: #82918c;
background:
linear-gradient(180deg, rgb(255 255 255 / 3.2%), rgb(255 255 255 / 1.8%));
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
background:
linear-gradient(180deg, rgb(0 255 136 / 3.4%), transparent 92px),
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent);
}
.script-eval-v5-history-list {
padding: 2px 8px 2px 0;
}
.script-eval-v5-history-item {
min-height: 68px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
}
.script-eval-v5-lp-bottom {
padding: 18px 22px 22px;
background:
linear-gradient(180deg, rgb(255 255 255 / 2.2%), transparent 60px),
#111414;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3.5%);
}
.script-eval-v5-export-btn {
border-color: rgb(255 255 255 / 7%);
background:
linear-gradient(180deg, rgb(255 255 255 / 3.5%), rgb(255 255 255 / 1.8%)),
#111414;
color: #7f8d88;
}
.script-eval-v5-export-btn:not(:disabled):hover {
border-color: rgb(0 255 136 / 22%);
color: #c7d5d0;
background:
linear-gradient(180deg, rgb(0 255 136 / 8%), rgb(255 255 255 / 2%)),
#111414;
}
.script-eval-v5-eval-btn:disabled,
.script-eval-v5-export-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.script-eval-v5-right-topbar {
backdrop-filter: blur(14px);
background:
linear-gradient(180deg, rgb(18 22 21 / 92%), rgb(12 14 14 / 88%));
}
.script-eval-v5-right-content:not(.is-report) {
background:
radial-gradient(circle at 50% 43%, rgb(0 255 136 / 5%), transparent 32%),
linear-gradient(180deg, transparent, rgb(0 0 0 / 12%));
}
.script-eval-v5-upload-card-title {
color: #f0fff8;
}
.script-eval-v5-upload-card-desc {
max-width: 540px;
color: #96a5a0;
}
.script-eval-v5-statusbar {
background:
linear-gradient(180deg, rgb(17 20 20 / 84%), rgb(10 12 12 / 92%));
}
@media (max-height: 760px) and (min-width: 901px) {
.script-eval-v5-left-main .script-eval-v5-lp-section {
padding-block: 12px;
}
.script-eval-v5-upload-zone {
min-height: 156px;
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
min-height: 176px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 110px;
}
}
@media (max-width: 680px) {
.script-eval-v5-left-main .script-eval-v5-lp-section {
padding-inline: 16px;
}
.script-eval-v5-upload-zone {
min-height: 164px;
}
.script-eval-v5-lp-bottom {
padding: 14px 16px 18px;
}
.script-eval-v5-right-content:not(.is-report) {
padding-top: 22px;
}
}
/* Ecommerce-aligned tone pass: restrained dark SaaS surfaces, no depth shadows. */
.script-eval-v5 {
--v5-bg: #0d0d0f;
--v5-bg2: #151719;
--v5-bg3: #181b1d;
--v5-bg4: #1d2022;
--v5-bg5: #222629;
--v5-border: rgba(255, 255, 255, 0.08);
--v5-border2: rgba(255, 255, 255, 0.12);
--v5-panel: #151719;
--v5-panel-2: #181b1d;
--v5-panel-3: #101214;
--v5-line: rgba(255, 255, 255, 0.08);
--v5-line-strong: rgba(0, 255, 136, 0.24);
--v5-green-deep: rgba(0, 255, 136, 0.055);
--v5-green-soft: rgba(0, 255, 136, 0.09);
--v5-green-border: rgba(0, 255, 136, 0.24);
--v5-shadow-soft: none;
--v5-shadow-tight: none;
background:
radial-gradient(circle at 24% 0%, rgba(0, 255, 136, 0.038), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 160px),
var(--v5-bg);
}
.script-eval-v5-page {
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.014), transparent 24%, transparent 76%, rgba(255, 255, 255, 0.012)),
transparent;
}
.script-eval-v5-left,
.script-eval-v5-right {
background: var(--v5-panel);
box-shadow: none;
}
.script-eval-v5-left {
border-right-color: var(--v5-line);
}
.script-eval-v5-left-main .script-eval-v5-lp-section,
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
background: transparent;
border-bottom-color: var(--v5-line);
box-shadow: none;
}
.script-eval-v5-lp-label {
color: #a7b3af;
letter-spacing: 0.02em;
}
.script-eval-v5-lp-label::before {
background: var(--v5-green);
box-shadow: none;
opacity: 0.72;
}
.script-eval-v5-upload-zone,
.script-eval-v5-info-empty,
.script-eval-v5-history-empty,
.script-eval-v5-info-item,
.script-eval-v5-history-item,
.script-eval-v5-loading,
.script-eval-v5-illustration-hit,
.script-eval-report__score-block,
.script-eval-report__chart-card,
.script-eval-report__path-card,
.script-eval-report__finding-group p {
border-color: var(--v5-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032), transparent 58%),
var(--v5-panel-2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.script-eval-v5-upload-zone {
border-style: dashed;
}
.script-eval-v5-upload-zone::after {
display: none;
}
.script-eval-v5-upload-zone:hover,
.script-eval-v5-upload-zone:focus-visible,
.script-eval-v5.is-ready .script-eval-v5-upload-zone,
.script-eval-v5.is-complete .script-eval-v5-upload-zone {
border-color: var(--v5-green-border);
background:
radial-gradient(circle at 50% 0%, rgba(0, 255, 136, 0.075), transparent 58%),
var(--v5-panel-3);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.028);
}
.script-eval-v5-upload-icon,
.script-eval-v5-upload-card-icon {
border-color: rgba(0, 255, 136, 0.18);
border-radius: 10px;
background: rgba(0, 255, 136, 0.09);
box-shadow: none;
}
.script-eval-v5-upload-btn,
.script-eval-v5-eval-btn {
background: var(--v5-green);
color: #061014;
box-shadow: none;
}
.script-eval-v5-upload-btn:hover,
.script-eval-v5-eval-btn:hover:not(:disabled) {
background: var(--v5-green-dim);
transform: none;
box-shadow: none;
}
.script-eval-v5-upload-done,
.script-eval-v5-history-item.is-active,
.script-eval-v5-error,
.script-eval-report__chart-note,
.script-eval-report__grade {
box-shadow: none;
}
.script-eval-v5-upload-done {
border-color: var(--v5-green-border);
background:
linear-gradient(180deg, rgba(0, 255, 136, 0.085), rgba(0, 255, 136, 0.035)),
var(--v5-panel-2);
}
.script-eval-v5-history-item:hover {
border-color: rgba(255, 255, 255, 0.13);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 58%),
var(--v5-panel-2);
transform: none;
box-shadow: none;
}
.script-eval-v5-history-item.is-active {
border-color: var(--v5-green-border);
background:
linear-gradient(90deg, rgba(0, 255, 136, 0.08), rgba(0, 255, 136, 0.025)),
var(--v5-panel-2);
}
.script-eval-v5-lp-bottom,
.script-eval-v5-right-topbar,
.script-eval-v5-statusbar {
background: rgba(21, 23, 25, 0.96);
border-color: var(--v5-line);
box-shadow: none;
backdrop-filter: none;
}
.script-eval-v5-export-btn,
.script-eval-v5-action-btn,
.script-eval-v5-retry-btn {
border-color: var(--v5-line);
background: rgba(255, 255, 255, 0.035);
color: #aeb8b1;
box-shadow: none;
}
.script-eval-v5-export-btn:hover:not(:disabled),
.script-eval-v5-action-btn:hover,
.script-eval-v5-retry-btn:hover {
border-color: var(--v5-green-border);
background: rgba(0, 255, 136, 0.07);
color: #d9fff0;
}
.script-eval-v5-right-content:not(.is-report) {
background:
radial-gradient(circle at 50% 0%, rgba(0, 255, 136, 0.034), transparent 44%),
transparent;
}
.script-eval-v5-illustration-hit:hover,
.script-eval-v5-illustration-hit:focus-visible {
background:
linear-gradient(180deg, rgba(0, 255, 136, 0.06), transparent 58%),
var(--v5-panel-2);
box-shadow: none;
}
.script-eval-report {
--report-bg: #0d0d0f;
--report-panel: #151719;
--report-panel-2: #101214;
--report-row: #181b1d;
--report-border: rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 180px),
var(--report-bg);
}
.script-eval-report::before,
.script-eval-report::after {
opacity: 0.28;
}
.script-eval-report__bar-fill {
box-shadow: none;
}
.script-eval-v5.is-complete .script-eval-v5-status-dot,
.script-eval-v5.is-ready .script-eval-v5-status-dot {
box-shadow: none;
}
-146
View File
@@ -826,149 +826,3 @@
border-color: var(--accent); border-color: var(--accent);
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;
}
}
+702 -28
View File
@@ -1485,6 +1485,40 @@
opacity: 1; opacity: 1;
} }
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 24px;
width: 24px;
height: 24px;
margin-top: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
}
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option-icon img {
display: block;
width: 16px;
height: 16px;
object-fit: contain;
filter: brightness(0) invert(1);
opacity: 0.86;
}
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option:hover .ai-workbench-select-chip__option-icon,
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option.is-active .ai-workbench-select-chip__option-icon {
border-color: rgba(var(--accent-rgb), 0.2);
background: rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option:hover .ai-workbench-select-chip__option-icon img,
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option.is-active .ai-workbench-select-chip__option-icon img {
opacity: 1;
}
.web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option-copy { .web-shell[data-ui-theme="dark-green"] .ai-workbench-select-chip__option-copy {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -4179,12 +4213,21 @@
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) > .project-card__empty--dark { .web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) > .project-card__empty--dark {
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--bg-inset, transparent); background: #202020;
color: rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.22);
} }
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) > .project-card__empty--dark .anticon { .web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) > .project-card__empty--dark .anticon {
font-size: 40px; font-size: 34px;
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new)::after {
content: "";
grid-area: 1 / 1;
align-self: end;
height: 58%;
background: rgba(0, 0, 0, 0.86);
pointer-events: none;
} }
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__caption, .web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__caption,
@@ -4200,35 +4243,18 @@
padding-block: 0; padding-block: 0;
background: none; background: none;
color: rgba(255, 255, 255, 0.78); color: rgba(255, 255, 255, 0.78);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms ease, transform 220ms ease;
pointer-events: none;
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new):hover .project-card__caption {
opacity: 1; opacity: 1;
transform: translateY(0); transform: none;
} }
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__meta { .web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__meta {
align-self: end; align-self: end;
padding-block: 0 16px; padding-block: 0 16px;
opacity: 0;
transform: translateY(8px);
transition: opacity 220ms ease, transform 220ms ease;
}
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new):hover .project-card__meta {
opacity: 1;
transform: translateY(0);
} }
.web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__meta strong { .web-shell[data-ui-theme="dark-green"] .community-page .project-card:not(.project-card--new) .project-card__meta strong {
color: #fff; color: #fff;
font-size: 15px; font-size: 15px;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.72);
} }
.web-shell[data-ui-theme="dark-green"] .community-filter-bar { .web-shell[data-ui-theme="dark-green"] .community-filter-bar {
@@ -6857,6 +6883,649 @@
} }
} }
/* Profile center: responsive workspace refinement. */
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard {
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.035), transparent 220px),
var(--dg-page);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__banner {
height: clamp(156px, 18vw, 214px);
background:
linear-gradient(135deg, rgba(var(--accent-rgb), 0.1), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent),
var(--bg-surface);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__banner-overlay {
background:
linear-gradient(180deg, rgba(13, 13, 15, 0.12), rgba(13, 13, 15, 0.72)),
linear-gradient(90deg, rgba(13, 13, 15, 0.68), transparent 42%);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__body {
grid-template-columns: minmax(270px, 300px) minmax(0, 1fr);
gap: clamp(18px, 2.4vw, 30px);
width: min(1180px, calc(100% - 48px));
min-height: auto;
margin-top: -58px;
padding-bottom: clamp(36px, 5vw, 64px);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__sidebar,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main-tabs,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__section,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__review-item,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__empty-state,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__upload-card {
border-color: rgba(255, 255, 255, 0.075);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.026), transparent),
rgba(21, 23, 25, 0.96);
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__sidebar {
gap: 16px;
padding: clamp(16px, 2vw, 22px);
border-radius: var(--radius-md);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-ring::before {
opacity: 0.45;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-ring .profile-page__avatar,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar {
width: 82px;
height: 82px;
border-width: 3px;
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 82px;
height: 82px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge {
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__username {
font-size: clamp(19px, 1.7vw, 22px);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__bio-display,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__bio {
border-color: rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.024);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__bio-display span {
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 0;
border: 0;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__count {
min-width: 0;
padding: 12px 8px;
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.024);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__count strong {
font-size: clamp(18px, 1.5vw, 22px);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card {
display: grid;
gap: 10px;
width: 100%;
padding: 11px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__list-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 100%;
padding: 3px;
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__list-tabs button {
min-width: 0;
min-height: 30px;
padding: 0 8px;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__upload-card--meta {
grid-template-columns: 1fr;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__meta-item {
min-height: auto;
padding: 10px 11px;
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__meta-item strong {
font-size: 15px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__share-btn {
min-height: 42px;
border-radius: 10px;
font-size: 13px;
font-weight: 650;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__share-btn:hover {
transform: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__share-btn--primary {
background: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__share-btn--secondary {
border-color: rgba(255, 255, 255, 0.08);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main {
gap: 16px;
min-width: 0;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
min-height: 50px;
margin: 0;
padding: 5px;
border-radius: var(--radius-md);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main-tabs button {
min-width: 0;
min-height: 40px;
padding: 0 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__section {
display: grid;
gap: 14px;
padding: clamp(14px, 1.8vw, 18px);
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-md);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__section-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-body);
font-size: 15px;
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__section-label::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-grid,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__review-list {
gap: 12px;
max-height: none;
overflow: visible;
padding-right: 0;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-grid {
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__review-list {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card {
min-height: 128px;
padding: 15px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card {
grid-template-columns: 92px minmax(0, 1fr);
align-items: stretch;
gap: 12px;
min-height: 116px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 88px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(var(--accent-rgb), 0.055), transparent 64%),
rgba(255, 255, 255, 0.024);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview:not(.has-media) {
border-color: rgba(255, 255, 255, 0.07);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent),
rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview img,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview video {
width: 100%;
height: 100%;
min-height: 88px;
object-fit: cover;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: 1px solid rgba(var(--accent-rgb), 0.18);
border-radius: 12px;
background: rgba(var(--accent-rgb), 0.07);
color: rgba(var(--accent-rgb), 0.92);
font-size: 16px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-badge {
position: absolute;
top: 7px;
right: 7px;
max-width: calc(100% - 14px);
overflow: hidden;
padding: 3px 7px;
border-radius: 999px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
background: rgba(8, 14, 12, 0.76);
color: var(--accent);
font-size: 10px;
font-weight: 700;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-head {
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-head strong {
min-width: 0;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__delete-project {
flex: 0 0 26px;
width: 26px;
height: 26px;
margin: -3px -2px 0 0;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 8px;
background: rgba(255, 255, 255, 0.026);
color: var(--fg-soft);
opacity: 0.72;
transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__delete-project:hover,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__delete-project:focus-visible {
border-color: rgba(255, 118, 118, 0.28);
background: rgba(255, 118, 118, 0.08);
color: #ff9a9d;
opacity: 1;
outline: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-body {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card:hover,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__review-item:hover {
border-color: rgba(var(--accent-rgb), 0.28);
transform: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__empty-state {
min-height: 240px;
padding: clamp(30px, 4vw, 46px) 24px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-bar {
margin-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-tabs {
width: fit-content;
max-width: 100%;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-tabs button {
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__upload-card--meta {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 12px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__meta-item {
min-height: 78px;
}
@media (max-width: 960px) {
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__body {
grid-template-columns: 1fr;
width: min(760px, calc(100% - 36px));
margin-top: -44px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__sidebar {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(220px, 0.8fr);
align-items: start;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__sidebar-head {
grid-row: span 5;
align-items: flex-start;
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
align-self: stretch;
}
}
@media (max-width: 640px) {
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__banner {
height: 140px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__body {
width: min(100% - 28px, 560px);
margin-top: -30px;
padding-bottom: 84px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__sidebar {
display: flex;
gap: 12px;
padding: 16px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__sidebar-head {
align-items: center;
text-align: center;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-ring .profile-page__avatar,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar {
width: 72px;
height: 72px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 72px;
height: 72px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__count {
padding: 10px 6px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main-tabs {
display: flex;
overflow-x: auto;
scrollbar-width: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main-tabs::-webkit-scrollbar {
display: none;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__main-tabs button {
flex: 0 0 auto;
min-width: 88px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__section {
padding: 14px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-grid,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__review-list {
grid-template-columns: 1fr;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card {
min-height: 118px;
padding: 13px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card {
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview img,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview video {
min-height: 82px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-head {
align-items: center;
flex-direction: row;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__upload-card--meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 420px) {
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__body {
width: min(100% - 20px, 420px);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__share-btn {
min-height: 40px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card {
grid-template-columns: 78px minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview img,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__list-card-preview video {
min-height: 76px;
}
}
/* Profile center: lock media card rhythm for dense libraries. */
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card {
grid-template-columns: 92px minmax(0, 1fr);
height: 126px;
min-height: 126px;
max-height: 126px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview {
width: 92px;
height: 96px;
min-height: 96px;
max-height: 96px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview img,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview video {
height: 96px;
min-height: 96px;
max-height: 96px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-body {
display: grid;
grid-template-rows: 18px 36px 18px;
align-content: space-between;
gap: 8px;
height: 96px;
min-height: 96px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-head {
min-height: 18px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-head strong {
display: block;
width: 100%;
line-height: 1.25;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card p {
min-height: 36px;
max-height: 36px;
line-height: 1.5;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-meta {
align-self: end;
min-height: 18px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-meta span:last-child {
color: var(--fg-soft);
}
@media (max-width: 640px) {
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card {
grid-template-columns: 88px minmax(0, 1fr);
height: 112px;
min-height: 112px;
max-height: 112px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview {
width: 88px;
height: 86px;
min-height: 86px;
max-height: 86px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview img,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview video {
height: 86px;
min-height: 86px;
max-height: 86px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-body {
grid-template-rows: 16px 32px 16px;
gap: 6px;
height: 86px;
min-height: 86px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card p {
min-height: 32px;
max-height: 32px;
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-meta {
min-height: 16px;
}
}
@media (max-width: 420px) {
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card {
grid-template-columns: 78px minmax(0, 1fr);
height: 104px;
min-height: 104px;
max-height: 104px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview {
width: 78px;
height: 78px;
min-height: 78px;
max-height: 78px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview img,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-preview video {
height: 78px;
min-height: 78px;
max-height: 78px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__media-card .profile-page__list-card-body {
height: 78px;
min-height: 78px;
}
}
/* Ecommerce generation page: keep its carousel and composer independent from /* Ecommerce generation page: keep its carousel and composer independent from
the community carousel rules that share class names. */ the community carousel rules that share class names. */
.web-shell[data-ui-theme="dark-green"] .ecommerce-landing-page { .web-shell[data-ui-theme="dark-green"] .ecommerce-landing-page {
@@ -8956,18 +9625,23 @@
/* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */ /* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas { .web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
background: background-image:
radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%), radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%),
radial-gradient(circle at 72% 88%, rgba(255, 255, 255, 0.035), transparent 28%), radial-gradient(circle at 72% 88%, rgba(255, 255, 255, 0.035), transparent 28%),
linear-gradient(rgba(255, 255, 255, 0.026) 1px, transparent 1px), radial-gradient(
linear-gradient(90deg, rgba(255, 255, 255, 0.026) 1px, transparent 1px), circle,
var(--dg-page); rgba(148, 163, 184, 0.34) 0 var(--canvas-bg-dot, 1.35px),
transparent calc(var(--canvas-bg-dot, 1.35px) + 0.55px)
);
background-color: var(--dg-page);
background-position:
center,
center,
var(--canvas-bg-x, 0px) var(--canvas-bg-y, 0px);
background-size: background-size:
auto, auto,
auto, auto,
24px 24px, var(--canvas-bg-size, 24px) var(--canvas-bg-size, 24px);
24px 24px,
auto;
color: var(--fg-body); color: var(--fg-body);
} }
+2 -4
View File
@@ -14,6 +14,7 @@ export type WebViewKey =
| "sizeTemplate" | "sizeTemplate"
| "scriptTokens" | "scriptTokens"
| "tokenUsage" | "tokenUsage"
| "settings"
| "imageWorkbench" | "imageWorkbench"
| "resolutionUpscale" | "resolutionUpscale"
| "digitalHuman" | "digitalHuman"
@@ -25,10 +26,7 @@ export type WebViewKey =
| "communityReview" | "communityReview"
| "communityCaseAdd" | "communityCaseAdd"
| "report" | "report"
| "providerHealth" | "providerHealth";
| "userAgreement"
| "privacyPolicy"
| "not-found";
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera"; export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
-3
View File
@@ -1,5 +1,4 @@
const ERROR_REPORT_ENDPOINT = "/api/client-errors"; const ERROR_REPORT_ENDPOINT = "/api/client-errors";
const CLIENT_ERROR_REPORTING_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_REPORTING === "1";
interface ErrorReport { interface ErrorReport {
message: string; message: string;
@@ -45,8 +44,6 @@ function scheduleFlush() {
} }
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") { 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 err = error instanceof Error ? error : new Error(String(error));
const report: ErrorReport = { const report: ErrorReport = {
message: err.message, message: err.message,