feat: 邮箱注册验证 + 9项功能修复与优化
【认证系统】 - 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword) - register-email 现在需要验证码 - 服务端新增 email_verification_codes 表 + patch-email-verification.js - App.tsx 登录后 emailVerified 检查提醒 - keyServerClient token 显式传递修复 401 错误 【电商模块】 - 自动推进: 策划完成后自动生成分镜图/视频 - 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词 - 任务持久化指纹修复 (图片数量替代 blob URL) - 新增「视频换装」入口 (happyhorse-1.0-video-edit) 【剧本评分】 - 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取) - 历史记录支持点击查看/恢复评测结果 【画布】 - ReactFlow 节点禁止内置拖拽避免冲突 - 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标) 【页面修复】 - 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题 - 资产库新增悬停删除按钮 - scriptEvalClient 改用服务端 /api/ai/chat 端点 - TokenUsagePage 未登录跳过 API 调用
This commit is contained in:
@@ -0,0 +1,291 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
// ── Patch 1: context.js ──────────────────────────────────────
|
||||||
|
const ctxPath = "/opt/omniai-server/src/routes/context.js";
|
||||||
|
let ctx = fs.readFileSync(ctxPath, "utf8");
|
||||||
|
|
||||||
|
const smsMaxLine = "const SMS_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.SMS_CODE_MAX_ATTEMPTS) || 5);";
|
||||||
|
const emailConsts = `
|
||||||
|
const EMAIL_PURPOSES = new Set(["register", "login", "reset"]);
|
||||||
|
const EMAIL_CODE_TTL_MINUTES = Math.max(1, Number(process.env.EMAIL_CODE_TTL_MINUTES) || 10);
|
||||||
|
const EMAIL_CODE_COOLDOWN_SECONDS = Math.max(10, Number(process.env.EMAIL_CODE_COOLDOWN_SECONDS) || 60);
|
||||||
|
const EMAIL_CODE_MAX_ATTEMPTS = Math.max(1, Number(process.env.EMAIL_CODE_MAX_ATTEMPTS) || 5);`;
|
||||||
|
|
||||||
|
if (!ctx.includes("EMAIL_PURPOSES")) {
|
||||||
|
ctx = ctx.replace(smsMaxLine, smsMaxLine + emailConsts);
|
||||||
|
console.log("[ctx] added EMAIL_PURPOSES");
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterConsume = ' await pool.query("UPDATE sms_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);\n return true;\n}';
|
||||||
|
const emailFuncs = `
|
||||||
|
function hashEmailCode(email, code) {
|
||||||
|
const secret = process.env.EMAIL_CODE_SECRET || process.env.JWT_SECRET || "omniai-dev-email-secret";
|
||||||
|
return crypto.createHash("sha256").update(email + ":" + code + ":" + secret).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailCode(email, code, purpose) {
|
||||||
|
const provider = String(process.env.EMAIL_PROVIDER || "mock").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (provider === "smtp") {
|
||||||
|
const nodemailer = require("nodemailer");
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: process.env.SMTP_SECURE === "1",
|
||||||
|
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||||
|
});
|
||||||
|
|
||||||
|
const purposeText = purpose === "register" ? "\u6ce8\u518c" : purpose === "login" ? "\u767b\u5f55" : "\u91cd\u7f6e\u5bc6\u7801";
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||||
|
to: email,
|
||||||
|
subject: "[OmniAI] \u90ae\u7bb1\u9a8c\u8bc1\u7801",
|
||||||
|
text: "\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a" + code + "\n\u7528\u9014\uff1a" + purposeText + "\n\u6709\u6548\u671f\uff1a" + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + " \u5206\u949f\n\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002",
|
||||||
|
html: '<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px"><h2 style="color:#333">OmniAI \u90ae\u7bb1\u9a8c\u8bc1</h2><p style="font-size:16px;color:#555">\u60a8\u7684\u9a8c\u8bc1\u7801\u662f\uff1a</p><p style="font-size:32px;font-weight:bold;letter-spacing:6px;color:#1677ff;margin:16px 0">' + code + '</p><p style="color:#888">\u7528\u9014\uff1a' + purposeText + '</p><p style="color:#888">\u6709\u6548\u671f\uff1a' + String(process.env.EMAIL_CODE_TTL_MINUTES || 10) + ' \u5206\u949f</p><hr style="border:none;border-top:1px solid #eee;margin:24px 0"><p style="color:#aaa;font-size:13px">\u5982\u679c\u4e0d\u662f\u60a8\u672c\u4eba\u64cd\u4f5c\uff0c\u8bf7\u5ffd\u7565\u6b64\u90ae\u4ef6\u3002</p></div>',
|
||||||
|
});
|
||||||
|
return { provider: "smtp" };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[email:" + purpose + "] " + email + " verification code: " + code + " (mock provider)");
|
||||||
|
return { provider: "mock", devCode: process.env.EMAIL_DEV_RETURN_CODE === "1" ? code : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeEmailCode(email, code, purpose) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT id, code_hash, attempts FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND consumed_at IS NULL AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1",
|
||||||
|
[email, purpose]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return false;
|
||||||
|
if (Number(row.attempts || 0) >= EMAIL_CODE_MAX_ATTEMPTS) return false;
|
||||||
|
|
||||||
|
const expectedHash = hashEmailCode(email, String(code || "").trim());
|
||||||
|
if (row.code_hash !== expectedHash) {
|
||||||
|
await pool.query("UPDATE email_verification_codes SET attempts = attempts + 1 WHERE id = $1", [row.id]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await pool.query("UPDATE email_verification_codes SET consumed_at = NOW() WHERE id = $1", [row.id]);
|
||||||
|
return true;
|
||||||
|
}`;
|
||||||
|
|
||||||
|
if (!ctx.includes("hashEmailCode")) {
|
||||||
|
ctx = ctx.replace(afterConsume, afterConsume + emailFuncs);
|
||||||
|
console.log("[ctx] added email functions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update exports
|
||||||
|
if (!ctx.includes("EMAIL_PURPOSES,")) {
|
||||||
|
ctx = ctx.replace(" EMAIL_PATTERN,\n SMS_PURPOSES,", " EMAIL_PATTERN,\n EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n SMS_PURPOSES,");
|
||||||
|
}
|
||||||
|
if (!ctx.includes("hashEmailCode,")) {
|
||||||
|
ctx = ctx.replace(" sendSmsCode,\n createLoginResultForUserId,", " sendSmsCode,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n createLoginResultForUserId,");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(ctxPath, ctx, "utf8");
|
||||||
|
console.log("[ctx] written");
|
||||||
|
|
||||||
|
// ── Patch 2: auth.js ─────────────────────────────────────────
|
||||||
|
const authPath = "/opt/omniai-server/src/routes/auth.js";
|
||||||
|
let auth = fs.readFileSync(authPath, "utf8");
|
||||||
|
|
||||||
|
// 2a. Add imports inside context.js destructuring
|
||||||
|
if (!auth.includes("hashEmailCode,")) {
|
||||||
|
auth = auth.replace(
|
||||||
|
'} = require("./context");',
|
||||||
|
' EMAIL_PURPOSES,\n EMAIL_CODE_TTL_MINUTES,\n EMAIL_CODE_COOLDOWN_SECONDS,\n EMAIL_CODE_MAX_ATTEMPTS,\n hashEmailCode,\n sendEmailCode,\n consumeEmailCode,\n} = require("./context");'
|
||||||
|
);
|
||||||
|
console.log("[auth] added imports");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b. Insert new routes before module.exports
|
||||||
|
const newRoutes = `
|
||||||
|
// ============================================================
|
||||||
|
// Email verification routes
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
router.post("/auth/email/send-code", async (req, res) => {
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
const purpose = String(req.body?.purpose || "register");
|
||||||
|
const emailError = validateEmail(email);
|
||||||
|
if (emailError) return res.status(400).json({ error: emailError });
|
||||||
|
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
|
||||||
|
|
||||||
|
if (purpose === "register") {
|
||||||
|
const inviteOk = await ensureBetaInviteCode(req, res);
|
||||||
|
if (!inviteOk) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows: recentCodes } = await pool.query(
|
||||||
|
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = $2 AND created_at > NOW() - ($3::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
|
||||||
|
[email, purpose, EMAIL_CODE_COOLDOWN_SECONDS]
|
||||||
|
);
|
||||||
|
if (recentCodes.length > 0) {
|
||||||
|
return res.status(429).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purpose === "register") {
|
||||||
|
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) LIMIT 1", [email]);
|
||||||
|
if (existing.length > 0) return res.status(409).json({ error: "\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purpose === "login" || purpose === "reset") {
|
||||||
|
const { rows: existing } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
|
||||||
|
if (existing.length === 0) return res.status(404).json({ error: "\u8be5\u90ae\u7bb1\u5c1a\u672a\u6ce8\u518c" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateSmsCode();
|
||||||
|
const codeHash = hashEmailCode(email, code);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, $2, $3, NOW() + ($4::text || ' minutes')::interval)",
|
||||||
|
[email, purpose, codeHash, EMAIL_CODE_TTL_MINUTES]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendResult = await sendEmailCode(email, code, purpose);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
provider: sendResult.provider,
|
||||||
|
ttlSeconds: EMAIL_CODE_TTL_MINUTES * 60,
|
||||||
|
cooldownSeconds: EMAIL_CODE_COOLDOWN_SECONDS,
|
||||||
|
...(sendResult.devCode ? { devCode: sendResult.devCode } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auth/email/send-code] failed", error);
|
||||||
|
res.status(500).json({ error: "\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/email/verify", async (req, res) => {
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
const code = String(req.body?.code || "").trim();
|
||||||
|
const purpose = String(req.body?.purpose || "register");
|
||||||
|
const emailError = validateEmail(email);
|
||||||
|
if (emailError) return res.status(400).json({ error: emailError });
|
||||||
|
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
||||||
|
if (!EMAIL_PURPOSES.has(purpose)) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u7528\u9014\u65e0\u6548" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verified = await consumeEmailCode(email, code, purpose);
|
||||||
|
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
||||||
|
if (purpose === "register" || purpose === "login") {
|
||||||
|
await pool.query("UPDATE users SET email_verified = 1 WHERE LOWER(email) = LOWER($1)", [email]);
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auth/email/verify] failed", error);
|
||||||
|
res.status(500).json({ error: "\u9a8c\u8bc1\u5931\u8d25" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/forgot-password", async (req, res) => {
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
const emailError = validateEmail(email);
|
||||||
|
if (emailError) return res.status(400).json({ error: emailError });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query("SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND enabled = 1 LIMIT 1", [email]);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.json({ success: true, message: "\u5982\u679c\u8be5\u90ae\u7bb1\u5df2\u6ce8\u518c\uff0c\u91cd\u7f6e\u94fe\u63a5\u5df2\u53d1\u9001" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: recentCodes } = await pool.query(
|
||||||
|
"SELECT created_at FROM email_verification_codes WHERE email = $1 AND purpose = 'reset' AND created_at > NOW() - ($2::text || ' seconds')::interval ORDER BY created_at DESC LIMIT 1",
|
||||||
|
[email, EMAIL_CODE_COOLDOWN_SECONDS]
|
||||||
|
);
|
||||||
|
if (recentCodes.length > 0) {
|
||||||
|
return res.status(429).json({ error: "\u53d1\u9001\u592a\u9891\u7e41\uff0c\u8bf7 " + EMAIL_CODE_COOLDOWN_SECONDS + " \u79d2\u540e\u518d\u8bd5" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = generateSmsCode();
|
||||||
|
const codeHash = hashEmailCode(email, code);
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO email_verification_codes (email, purpose, code_hash, expires_at) VALUES ($1, 'reset', $2, NOW() + ($3::text || ' minutes')::interval)",
|
||||||
|
[email, codeHash, EMAIL_CODE_TTL_MINUTES]
|
||||||
|
);
|
||||||
|
await sendEmailCode(email, code, "reset");
|
||||||
|
res.json({ success: true, message: "\u91cd\u7f6e\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230\u60a8\u7684\u90ae\u7bb1" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auth/forgot-password] failed", error);
|
||||||
|
res.status(500).json({ error: "\u53d1\u9001\u5931\u8d25" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/reset-password", async (req, res) => {
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
const code = String(req.body?.code || "").trim();
|
||||||
|
const newPassword = String(req.body?.newPassword || "");
|
||||||
|
const emailError = validateEmail(email);
|
||||||
|
if (emailError) return res.status(400).json({ error: emailError });
|
||||||
|
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
||||||
|
const passwordError = validatePassword(newPassword);
|
||||||
|
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verified = await consumeEmailCode(email, code, "reset");
|
||||||
|
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
||||||
|
const hash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await pool.query("UPDATE users SET password_hash = $1 WHERE LOWER(email) = LOWER($2)", [hash, email]);
|
||||||
|
res.json({ success: true, message: "\u5bc6\u7801\u91cd\u7f6e\u6210\u529f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[auth/reset-password] failed", error);
|
||||||
|
res.status(500).json({ error: "\u5bc6\u7801\u91cd\u7f6e\u5931\u8d25" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!auth.includes("/auth/email/send-code")) {
|
||||||
|
const endMarker = "\n}\n\nmodule.exports = {";
|
||||||
|
auth = auth.replace(endMarker, "\n" + newRoutes + "}\n\nmodule.exports = {");
|
||||||
|
console.log("[auth] added new routes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. Update register-email to require verification code
|
||||||
|
// Replace: router.post("/auth/register-email" ... without code check
|
||||||
|
// With: router.post("/auth/register-email" ... with code verification
|
||||||
|
|
||||||
|
const oldRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
const usernameInput = String(req.body?.username || "").trim();
|
||||||
|
const password = String(req.body?.password || "");
|
||||||
|
|
||||||
|
const emailError = validateEmail(email);
|
||||||
|
if (emailError) return res.status(400).json({ error: emailError });
|
||||||
|
const passwordError = validatePassword(password);
|
||||||
|
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||||
|
const registrationInvite = await ensureRegistrationInvite(req, res);
|
||||||
|
if (!registrationInvite) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows: existingEmail }`;
|
||||||
|
|
||||||
|
const newRegisterEmail = ` router.post("/auth/register-email", async (req, res) => {
|
||||||
|
const email = normalizeEmail(req.body?.email);
|
||||||
|
const usernameInput = String(req.body?.username || "").trim();
|
||||||
|
const password = String(req.body?.password || "");
|
||||||
|
const code = String(req.body?.code || "").trim();
|
||||||
|
|
||||||
|
const emailError = validateEmail(email);
|
||||||
|
if (emailError) return res.status(400).json({ error: emailError });
|
||||||
|
if (!code) return res.status(400).json({ error: "\u7f3a\u5c11\u9a8c\u8bc1\u7801" });
|
||||||
|
const passwordError = validatePassword(password);
|
||||||
|
if (passwordError) return res.status(400).json({ error: passwordError });
|
||||||
|
const registrationInvite = await ensureRegistrationInvite(req, res);
|
||||||
|
if (!registrationInvite) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verified = await consumeEmailCode(email, code, "register");
|
||||||
|
if (!verified) return res.status(400).json({ error: "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f" });
|
||||||
|
|
||||||
|
const { rows: existingEmail }`;
|
||||||
|
|
||||||
|
if (auth.includes(oldRegisterEmail)) {
|
||||||
|
auth = auth.replace(oldRegisterEmail, newRegisterEmail);
|
||||||
|
console.log("[auth] updated register-email with verification");
|
||||||
|
} else {
|
||||||
|
console.log("[auth] WARNING: register-email pattern not found, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(authPath, auth, "utf8");
|
||||||
|
console.log("[auth] written");
|
||||||
|
console.log("\nDone.");
|
||||||
+23
-5
@@ -20,6 +20,7 @@ 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";
|
||||||
@@ -32,8 +33,10 @@ 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 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"));
|
||||||
@@ -56,7 +59,6 @@ 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 {
|
||||||
@@ -103,7 +105,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"ecommerce",
|
"ecommerce",
|
||||||
"scriptTokens",
|
"scriptTokens",
|
||||||
"tokenUsage",
|
"tokenUsage",
|
||||||
"settings",
|
|
||||||
"imageWorkbench",
|
"imageWorkbench",
|
||||||
"resolutionUpscale",
|
"resolutionUpscale",
|
||||||
"watermarkRemoval",
|
"watermarkRemoval",
|
||||||
@@ -116,10 +117,12 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
|||||||
"communityCaseAdd",
|
"communityCaseAdd",
|
||||||
"report",
|
"report",
|
||||||
"providerHealth",
|
"providerHealth",
|
||||||
|
"userAgreement",
|
||||||
|
"privacyPolicy",
|
||||||
"not-found",
|
"not-found",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "not-found"]);
|
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
|
||||||
|
|
||||||
function normalizeViewKey(rawView: string): WebViewKey {
|
function normalizeViewKey(rawView: string): WebViewKey {
|
||||||
const normalized =
|
const normalized =
|
||||||
@@ -127,6 +130,10 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
|||||||
? "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"
|
||||||
@@ -321,6 +328,11 @@ 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 /> },
|
||||||
@@ -838,6 +850,10 @@ 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) {
|
||||||
@@ -1112,8 +1128,6 @@ function App() {
|
|||||||
onSelectView={handleSetView}
|
onSelectView={handleSetView}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "settings":
|
|
||||||
return <SettingsPage />;
|
|
||||||
case "imageWorkbench":
|
case "imageWorkbench":
|
||||||
return (
|
return (
|
||||||
<ImageWorkbenchPage
|
<ImageWorkbenchPage
|
||||||
@@ -1153,6 +1167,10 @@ 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
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ 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;
|
||||||
@@ -290,6 +301,18 @@ 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",
|
||||||
|
|||||||
@@ -30,9 +30,26 @@ 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;
|
||||||
@@ -52,6 +69,19 @@ 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;
|
||||||
@@ -624,6 +654,21 @@ function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRechargeOrder(payload: unknown): RechargeOrderResult {
|
||||||
|
const raw = unwrapApiPayload(payload);
|
||||||
|
if (!isRecord(raw)) {
|
||||||
|
return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`),
|
||||||
|
status: toStringValue(raw.status, "pending"),
|
||||||
|
payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url),
|
||||||
|
qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl),
|
||||||
|
message: toNullableString(raw.message ?? raw.notice),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record<string, unknown> {
|
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();
|
||||||
@@ -714,6 +759,7 @@ 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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -731,6 +777,30 @@ 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", {
|
||||||
@@ -855,13 +925,23 @@ export const keyServerClient = {
|
|||||||
return normalizeProjectContent(response, projectId);
|
return normalizeProjectContent(response, projectId);
|
||||||
},
|
},
|
||||||
async getUsageSummary(): Promise<WebUsageSummary> {
|
async getUsageSummary(): Promise<WebUsageSummary> {
|
||||||
return normalizeUsageSummary(await request<unknown>("/user/usage/summary"));
|
const stored = readStoredSession();
|
||||||
|
return normalizeUsageSummary(await request<unknown>("/user/usage/summary", { token: stored?.token }));
|
||||||
},
|
},
|
||||||
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
||||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary"));
|
const stored = readStoredSession();
|
||||||
|
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary", { token: stored?.token }));
|
||||||
},
|
},
|
||||||
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
||||||
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits"));
|
const stored = readStoredSession();
|
||||||
|
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits", { token: stored?.token }));
|
||||||
|
},
|
||||||
|
async createRechargeOrder(input: RechargeOrderInput): Promise<RechargeOrderResult> {
|
||||||
|
const response = await request<unknown>("/payments/recharge-orders", {
|
||||||
|
method: "POST",
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
return normalizeRechargeOrder(response);
|
||||||
},
|
},
|
||||||
async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
||||||
const stored = readStoredSession();
|
const stored = readStoredSession();
|
||||||
@@ -929,8 +1009,8 @@ export const keyServerClient = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
|
async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> {
|
||||||
const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
|
const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||||
|
|
||||||
export interface ScriptEvalResult {
|
export interface ScriptEvalResult {
|
||||||
totalScore: number;
|
totalScore: number;
|
||||||
grade: string;
|
grade: string;
|
||||||
@@ -8,7 +10,6 @@ export interface ScriptEvalResult {
|
|||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
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短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
||||||
@@ -68,11 +69,9 @@ 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(DASHSCOPE_ENDPOINT, {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: buildAuthHeaders(),
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -92,11 +91,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await res.json();
|
const payload = await res.json();
|
||||||
const content: string = payload?.choices?.[0]?.message?.content
|
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
|
||||||
?? payload?.result?.content
|
|
||||||
?? payload?.content
|
|
||||||
?? payload?.text
|
|
||||||
?? (typeof payload === "string" ? payload : "");
|
|
||||||
|
|
||||||
if (!content) throw new Error("模型未返回有效内容");
|
if (!content) throw new Error("模型未返回有效内容");
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
interface ClientErrorItem {
|
export interface ClientErrorItem {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
stack?: string;
|
stack?: string;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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;
|
||||||
@@ -40,6 +41,7 @@ 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;
|
||||||
@@ -344,8 +346,8 @@ function AppShell({
|
|||||||
<dd>15155073618</dd>
|
<dd>15155073618</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="info-popover__links">
|
<div className="info-popover__links">
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}>用户协议</a>
|
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); setInfoOpen(false); }}>隐私政策</a>
|
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}>隐私政策</a>
|
||||||
</div>
|
</div>
|
||||||
</AnimatedPanel>
|
</AnimatedPanel>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,7 +358,7 @@ function AppShell({
|
|||||||
onClick={() => setRechargeOpen(true)}
|
onClick={() => setRechargeOpen(true)}
|
||||||
>
|
>
|
||||||
<WalletOutlined />
|
<WalletOutlined />
|
||||||
{displayedBalanceLabel}
|
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="profile-popover-anchor" ref={profileRef}>
|
<div className="profile-popover-anchor" ref={profileRef}>
|
||||||
<button
|
<button
|
||||||
@@ -471,8 +473,9 @@ function AppShell({
|
|||||||
<div className="web-shell__page">{children}</div>
|
<div className="web-shell__page">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||||
|
<CookieConsentBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ const NAV_ORDER: string[] = [
|
|||||||
"avatarConsole",
|
"avatarConsole",
|
||||||
"characterMix",
|
"characterMix",
|
||||||
"agent",
|
"agent",
|
||||||
"settings",
|
|
||||||
"login",
|
"login",
|
||||||
"profile",
|
"profile",
|
||||||
"report",
|
"report",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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;
|
||||||
@@ -107,6 +110,12 @@ const rechargeRules = [
|
|||||||
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
|
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [
|
||||||
|
{ id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" },
|
||||||
|
{ id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" },
|
||||||
|
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
|
||||||
|
];
|
||||||
|
|
||||||
interface RechargeModalProps {
|
interface RechargeModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -116,14 +125,43 @@ 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;
|
||||||
@@ -224,6 +262,44 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 () => {
|
const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => {
|
||||||
if (!contextMenu) return;
|
const target = asset || contextMenu?.asset;
|
||||||
const { asset } = contextMenu;
|
if (!target) return;
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
try {
|
try {
|
||||||
await assetClient.delete(asset.id);
|
await assetClient.delete(target.id);
|
||||||
setServerAssets((prev) => prev.filter((a) => a.id !== asset.id));
|
setServerAssets((prev) => prev.filter((a) => a.id !== target.id));
|
||||||
setServerNotice(`已删除 ${asset.name}`);
|
setServerNotice(`已删除 ${target.name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setServerNotice(err instanceof Error ? err.message : "删除失败");
|
setServerNotice(err instanceof Error ? err.message : "删除失败");
|
||||||
}
|
}
|
||||||
@@ -287,8 +287,8 @@ 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)}
|
||||||
@@ -313,6 +313,16 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="asset-card__delete"
|
||||||
|
title="删除素材"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleDeleteAsset(asset); }}
|
||||||
|
aria-label={`删除 ${asset.name}`}
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
|
|||||||
@@ -3717,6 +3717,9 @@ 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}
|
||||||
@@ -5531,6 +5534,11 @@ 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
|
||||||
@@ -5542,8 +5550,6 @@ 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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -5559,8 +5565,6 @@ 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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -5576,8 +5580,6 @@ 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, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
@@ -23,6 +23,7 @@ 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,
|
||||||
@@ -45,6 +46,34 @@ 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";
|
||||||
}
|
}
|
||||||
@@ -85,14 +114,20 @@ export default function EcommerceVideoWorkspace({
|
|||||||
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 keepaliveRestoredRef = useRef(false);
|
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
|
||||||
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 (keepaliveRestoredRef.current) return;
|
if (keepaliveRestoredFingerprintRef.current === inputFingerprint) return;
|
||||||
keepaliveRestoredRef.current = true;
|
keepaliveRestoredFingerprintRef.current = inputFingerprint;
|
||||||
const saved = loadEcommerceVideoState();
|
const saved = loadEcommerceVideoState(inputFingerprint);
|
||||||
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
|
||||||
@@ -102,13 +137,33 @@ export default function EcommerceVideoWorkspace({
|
|||||||
setPlanProgress((saved as { planProgress?: EcommerceVideoPlanProgress | null }).planProgress || null);
|
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({ stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||||
}, [stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||||
|
|
||||||
|
// ── Auto-advance: skip manual "next step" clicks ─────────
|
||||||
|
const autoAdvanceTriggeredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoAdvanceTriggeredRef.current) return;
|
||||||
|
const delay = 600;
|
||||||
|
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||||
|
autoAdvanceTriggeredRef.current = true;
|
||||||
|
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||||
|
autoAdvanceTriggeredRef.current = true;
|
||||||
|
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (stage === "idle" || stage === "cancelled") {
|
||||||
|
autoAdvanceTriggeredRef.current = false;
|
||||||
|
}
|
||||||
|
}, [stage, scenes, planResult]);
|
||||||
|
|
||||||
// ── Keep-alive: resume polling for running tasks ──────────
|
// ── Keep-alive: resume polling for running tasks ──────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -286,6 +341,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
: [];
|
: [];
|
||||||
const persist = (stageNow: EcommerceVideoStage) => {
|
const persist = (stageNow: EcommerceVideoStage) => {
|
||||||
saveEcommerceVideoState({
|
saveEcommerceVideoState({
|
||||||
|
inputFingerprint,
|
||||||
stage: stageNow,
|
stage: stageNow,
|
||||||
completedSteps: liveCompletedSteps,
|
completedSteps: liveCompletedSteps,
|
||||||
planResult: null,
|
planResult: null,
|
||||||
@@ -308,6 +364,9 @@ export default function EcommerceVideoWorkspace({
|
|||||||
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
livePlanProgress = { ...livePlanProgress, imageUrls: urls };
|
||||||
persist("planning");
|
persist("planning");
|
||||||
},
|
},
|
||||||
|
onUploadRejected: (messages) => {
|
||||||
|
if (messages.length) showNotice(`已跳过 ${messages.length} 张上传失败的图片`);
|
||||||
|
},
|
||||||
onPartialProgress: (progress) => {
|
onPartialProgress: (progress) => {
|
||||||
livePlanProgress = progress;
|
livePlanProgress = progress;
|
||||||
setPlanProgress(progress);
|
setPlanProgress(progress);
|
||||||
@@ -322,7 +381,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
setPlanProgress(null);
|
setPlanProgress(null);
|
||||||
setScenes(builtScenes);
|
setScenes(builtScenes);
|
||||||
setStage("planned");
|
setStage("planned");
|
||||||
saveEcommerceVideoState({ stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage: "planned", completedSteps: [...ALL_STEPS], planResult: result, planProgress: null, scenes: builtScenes, sourceImageUrls: result.imageUrls });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
if ((err as Error).name === "AbortError" && controller.signal.aborted) return;
|
||||||
const message = err instanceof Error ? err.message : "策划失败";
|
const message = err instanceof Error ? err.message : "策划失败";
|
||||||
@@ -362,7 +421,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||||
currentScenes = next;
|
currentScenes = next;
|
||||||
setScenes(next);
|
setScenes(next);
|
||||||
saveEcommerceVideoState({ stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage: "imaging", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||||
};
|
};
|
||||||
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
// Only redo scenes missing imageUrl — preserves successfully generated images on partial retry
|
||||||
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
const scenesToProcess = currentScenes.filter((s) => !s.imageUrl);
|
||||||
@@ -374,10 +433,22 @@ export default function EcommerceVideoWorkspace({
|
|||||||
await renderSceneImage(
|
await renderSceneImage(
|
||||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
||||||
{
|
{
|
||||||
onSceneImageSubmitted: (id, taskId) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)),
|
onSceneImageSubmitted: (id, taskId) => {
|
||||||
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||||
|
const storeId = generation.submitTask({ title: `分镜${id}图片`, type: "image", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "imaging" } });
|
||||||
|
sceneStoreIdMap.current.set(id, storeId);
|
||||||
|
},
|
||||||
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
onSceneImageProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||||
onSceneImageCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s)),
|
onSceneImageCompleted: (id, url) => {
|
||||||
onSceneImageFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s)),
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", progress: 100, imageUrl: url } : s));
|
||||||
|
const sid = sceneStoreIdMap.current.get(id);
|
||||||
|
if (sid) generation.markCompleted(sid, url);
|
||||||
|
},
|
||||||
|
onSceneImageFailed: (id, err2) => {
|
||||||
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "idle", error: err2 } : s));
|
||||||
|
const sid = sceneStoreIdMap.current.get(id);
|
||||||
|
if (sid) generation.markFailed(sid, err2);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
renderAbortRef.current,
|
renderAbortRef.current,
|
||||||
);
|
);
|
||||||
@@ -389,7 +460,7 @@ 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({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Phase 3: Video rendering from generated images ──────────
|
// ── Phase 3: Video rendering from generated images ──────────
|
||||||
@@ -404,7 +475,7 @@ export default function EcommerceVideoWorkspace({
|
|||||||
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
const persistScenes = (next: EcommerceVideoSceneTask[]) => {
|
||||||
currentScenes = next;
|
currentScenes = next;
|
||||||
setScenes(next);
|
setScenes(next);
|
||||||
saveEcommerceVideoState({ stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage: "rendering", completedSteps, planResult, scenes: next, sourceImageUrls });
|
||||||
};
|
};
|
||||||
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
// Only render scenes that haven't completed yet — preserves successful videos on partial retry
|
||||||
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
const scenesToProcess = currentScenes.filter((s) => s.imageUrl && s.status !== "completed");
|
||||||
@@ -417,10 +488,22 @@ export default function EcommerceVideoWorkspace({
|
|||||||
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) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
onSceneSubmitted: (id, taskId) => {
|
||||||
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||||
|
const storeId = generation.submitTask({ title: `分镜${id}视频`, type: "video", status: "running", progress: 0, prompt: scene.prompt, sourceView: "ecommerce", taskId, params: { sceneId: id, phase: "rendering" } });
|
||||||
|
sceneStoreIdMap.current.set(id, storeId);
|
||||||
|
},
|
||||||
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
onSceneProgress: (id, progress) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||||
onSceneCompleted: (id, url) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s)),
|
onSceneCompleted: (id, url) => {
|
||||||
onSceneFailed: (id, err2) => persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s)),
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "completed", progress: 100, resultUrl: url } : s));
|
||||||
|
const sid = sceneStoreIdMap.current.get(id);
|
||||||
|
if (sid) generation.markCompleted(sid, url);
|
||||||
|
},
|
||||||
|
onSceneFailed: (id, err2) => {
|
||||||
|
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, status: "failed", error: err2 } : s));
|
||||||
|
const sid = sceneStoreIdMap.current.get(id);
|
||||||
|
if (sid) generation.markFailed(sid, err2);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
renderAbortRef.current,
|
renderAbortRef.current,
|
||||||
);
|
);
|
||||||
@@ -436,7 +519,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({ stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
saveEcommerceVideoState({ inputFingerprint, stage: finalStage, completedSteps, planResult, scenes: currentScenes, sourceImageUrls });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
const handleCancel = () => { abortControllerRef.current?.abort(); renderAbortRef.current.current = true; setStage("cancelled"); };
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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;
|
||||||
@@ -19,6 +20,7 @@ interface EcommerceVideoKeepalive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveEcommerceVideoState(state: {
|
export function saveEcommerceVideoState(state: {
|
||||||
|
inputFingerprint: string;
|
||||||
stage: EcommerceVideoStage;
|
stage: EcommerceVideoStage;
|
||||||
completedSteps: PlanStep[];
|
completedSteps: PlanStep[];
|
||||||
planResult: EcommerceVideoPlanResult | null;
|
planResult: EcommerceVideoPlanResult | null;
|
||||||
@@ -38,7 +40,7 @@ export function saveEcommerceVideoState(state: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
export function loadEcommerceVideoState(inputFingerprint: string): 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;
|
||||||
@@ -48,6 +50,7 @@ export function loadEcommerceVideoState(): EcommerceVideoKeepalive | null {
|
|||||||
clearEcommerceVideoState();
|
clearEcommerceVideoState();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (parsed.inputFingerprint !== inputFingerprint) return null;
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -11,6 +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,
|
EcommerceVideoPlanProgress,
|
||||||
EcommerceVideoPlanResult,
|
EcommerceVideoPlanResult,
|
||||||
@@ -22,6 +23,7 @@ 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;
|
onPartialProgress?: (progress: EcommerceVideoPlanProgress) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Partial state from a previous run; steps with existing data are skipped. */
|
/** Partial state from a previous run; steps with existing data are skipped. */
|
||||||
@@ -47,19 +49,29 @@ export async function runVideoPlan(
|
|||||||
if (!progress.imageUrls?.length) {
|
if (!progress.imageUrls?.length) {
|
||||||
onStepStart("upload");
|
onStepStart("upload");
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
const rejected: string[] = [];
|
||||||
for (const srcUrl of imageDataUrls) {
|
for (const srcUrl of imageDataUrls) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(srcUrl);
|
const resp = await fetch(srcUrl);
|
||||||
const rawBlob = await resp.blob();
|
const rawBlob = await resp.blob();
|
||||||
const mimeType = SUPPORTED_IMAGE_TYPES.has(rawBlob.type) ? rawBlob.type : "image/png";
|
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||||
const result = await aiGenerationClient.uploadAssetBinary(blob, { mimeType, scope: "ecommerce-product" });
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ""));
|
||||||
|
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||||
imageUrls.push(result.url);
|
imageUrls.push(result.url);
|
||||||
} catch {
|
} catch (err) {
|
||||||
// skip images that fail to upload
|
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (rejected.length) {
|
||||||
|
progress.uploadWarnings = rejected;
|
||||||
|
callbacks.onUploadRejected?.(rejected);
|
||||||
|
}
|
||||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||||
progress.imageUrls = imageUrls;
|
progress.imageUrls = imageUrls;
|
||||||
onStepDone("upload");
|
onStepDone("upload");
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface EcommerceVideoPlanResult {
|
|||||||
export interface EcommerceVideoPlanProgress {
|
export interface EcommerceVideoPlanProgress {
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
imageDescription?: string;
|
imageDescription?: string;
|
||||||
|
uploadWarnings?: string[];
|
||||||
summary?: ProductSummary;
|
summary?: ProductSummary;
|
||||||
selling?: SellingPointResult;
|
selling?: SellingPointResult;
|
||||||
creatives?: CreativeOption[];
|
creatives?: CreativeOption[];
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ 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");
|
||||||
@@ -245,6 +253,70 @@ 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()) {
|
||||||
@@ -289,6 +361,10 @@ 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 "";
|
||||||
}
|
}
|
||||||
@@ -328,6 +404,10 @@ 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;
|
||||||
@@ -354,7 +434,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, username: username.trim() || undefined, betaCode });
|
: await keyServerClient.registerEmail({ email, password, code: emailCode, 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);
|
||||||
@@ -788,7 +868,31 @@ function ProfilePage({
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{authTab === "password" ? (
|
{showForgotPassword ? (
|
||||||
|
<div className="auth-page__forgot-box">
|
||||||
|
<p className="auth-page__forgot-title">重置密码</p>
|
||||||
|
{forgotStep === "email" ? (
|
||||||
|
<input value={forgotEmail} onChange={(e) => setForgotEmail(e.target.value)} placeholder="输入注册邮箱" type="email" className="auth-page__forgot-input" />
|
||||||
|
) : forgotStep === "code" ? (
|
||||||
|
<div className="auth-page__sms-row">
|
||||||
|
<input value={forgotCode} onChange={(e) => setForgotCode(e.target.value)} placeholder="输入验证码" maxLength={6} />
|
||||||
|
<button type="button" className="auth-page__sms-btn" disabled={emailCooldown > 0 || isSendingEmail} onClick={() => void handleSendEmailCode("reset")}>
|
||||||
|
{isSendingEmail ? "发送中" : emailCooldown > 0 ? `${emailCooldown}s` : "重新发送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input type="password" value={forgotPassword} onChange={(e) => setForgotPassword(e.target.value)} placeholder="输入新密码(至少 6 位)" className="auth-page__forgot-input" />
|
||||||
|
)}
|
||||||
|
<div className="auth-page__forgot-actions">
|
||||||
|
<button type="button" className="auth-page__forgot-cancel" onClick={() => { setShowForgotPassword(false); setForgotStep("email"); setForgotEmail(""); setForgotCode(""); setForgotPassword(""); setNotice(null); }}>取消</button>
|
||||||
|
<button type="button" className="auth-page__forgot-confirm" onClick={() => void handleForgotPassword()}>
|
||||||
|
{forgotStep === "newPassword" ? "重置密码" : "下一步"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!showForgotPassword && authTab === "password" ? (
|
||||||
<>
|
<>
|
||||||
<label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
|
<label className={`auth-page__field${fieldErrors.username ? " auth-page__field--error" : ""}`}>
|
||||||
<span>
|
<span>
|
||||||
@@ -819,13 +923,13 @@ function ProfilePage({
|
|||||||
</label>
|
</label>
|
||||||
{mode === "login" ? (
|
{mode === "login" ? (
|
||||||
<div className="auth-page__forgot">
|
<div className="auth-page__forgot">
|
||||||
<button type="button">忘记密码?</button>
|
<button type="button" onClick={() => { setShowForgotPassword(true); setForgotStep("email"); }}>忘记密码?</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{authTab === "email" ? (
|
{!showForgotPassword && authTab === "email" ? (
|
||||||
<>
|
<>
|
||||||
{mode === "register" ? (
|
{mode === "register" ? (
|
||||||
<label className="auth-page__field">
|
<label className="auth-page__field">
|
||||||
@@ -871,7 +975,7 @@ function ProfilePage({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{authTab === "phone" ? (
|
{!showForgotPassword && 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>
|
||||||
@@ -912,6 +1016,8 @@ 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}>
|
||||||
@@ -934,6 +1040,8 @@ function ProfilePage({
|
|||||||
<MobileOutlined />
|
<MobileOutlined />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ 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 {
|
||||||
@@ -54,6 +56,8 @@ const TEXT_FILE_EXTENSIONS = [
|
|||||||
".fountain",
|
".fountain",
|
||||||
".fdx",
|
".fdx",
|
||||||
".rtf",
|
".rtf",
|
||||||
|
".docx",
|
||||||
|
".doc",
|
||||||
".csv",
|
".csv",
|
||||||
".tsv",
|
".tsv",
|
||||||
".json",
|
".json",
|
||||||
@@ -99,7 +103,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 / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
const TEXT_FILE_HINT = "支持常见文本格式:TXT / MD / DOCX / Fountain / FDX / RTF / JSON / CSV / XML / HTML / YAML / LOG / 字幕等";
|
||||||
|
|
||||||
function loadHistory(): HistoryEntry[] {
|
function loadHistory(): HistoryEntry[] {
|
||||||
try {
|
try {
|
||||||
@@ -168,6 +172,69 @@ 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 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(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/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(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
|
||||||
|
}).filter(Boolean).join("\n").trim();
|
||||||
|
}
|
||||||
|
return currentLine.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const SCORE_DIMENSIONS: ScoreDimension[] = [
|
const SCORE_DIMENSIONS: ScoreDimension[] = [
|
||||||
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
|
{ key: "hook", label: "钩子设计", maxScore: 20, hint: "开篇吸引力·悬念设置·黄金三秒", detail: "开篇即抛出高概念钩子,悬念设置紧凑有力。" },
|
||||||
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
|
{ key: "character", label: "角色塑造", maxScore: 15, hint: "人物立体度·动机合理性·弧光设计", detail: "主角动机有铺垫,配角功能性较强,人物弧光尚可进一步深化。" },
|
||||||
@@ -222,6 +289,7 @@ 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);
|
||||||
@@ -251,7 +319,23 @@ 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 (readable) {
|
if (ext === ".docx") {
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const text = await extractDocxText(bytes);
|
||||||
|
if (text) {
|
||||||
|
setScript(text);
|
||||||
|
} else {
|
||||||
|
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
||||||
|
}
|
||||||
|
} else if (ext === ".doc") {
|
||||||
|
const text = await decodeTextFile(file);
|
||||||
|
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
|
||||||
|
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
|
||||||
|
} else if (readable) {
|
||||||
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
||||||
setScript(text);
|
setScript(text);
|
||||||
} else {
|
} else {
|
||||||
@@ -277,6 +361,8 @@ 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,
|
||||||
@@ -289,6 +375,20 @@ 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);
|
||||||
@@ -420,7 +520,9 @@ function ScriptTokensPage() {
|
|||||||
<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 === 0 ? " is-active" : ""}`}>
|
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
|
||||||
|
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
|
||||||
<div className="script-eval-v5-hi-left">
|
<div className="script-eval-v5-hi-left">
|
||||||
<div className="script-eval-v5-hi-name">{item.name}</div>
|
<div className="script-eval-v5-hi-name">{item.name}</div>
|
||||||
<div className="script-eval-v5-hi-date">{item.date}</div>
|
<div className="script-eval-v5-hi-date">{item.date}</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
SettingOutlined,
|
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
@@ -143,29 +142,22 @@ function TokenUsagePage({
|
|||||||
onSelectView,
|
onSelectView,
|
||||||
}: TokenUsagePageProps) {
|
}: TokenUsagePageProps) {
|
||||||
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
const [enterpriseUsage, setEnterpriseUsage] = useState<WebEnterpriseUsageSummary | null>(null);
|
||||||
const [enterpriseUsageLoading, setEnterpriseUsageLoading] = useState(false);
|
|
||||||
const [enterpriseUsageError, setEnterpriseUsageError] = useState<string | null>(null);
|
|
||||||
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
|
const isEnterpriseAdmin = session?.user.enterpriseRole === "admin";
|
||||||
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||||
|
|
||||||
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);
|
|
||||||
setEnterpriseUsageError(null);
|
|
||||||
try {
|
try {
|
||||||
setEnterpriseUsage(await loader());
|
setEnterpriseUsage(await loader());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setEnterpriseUsage(null);
|
setEnterpriseUsage(null);
|
||||||
setEnterpriseUsageError(error instanceof Error ? error.message : "用量数据暂时不可用");
|
|
||||||
} finally {
|
|
||||||
setEnterpriseUsageLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
}, [session, isEnterpriseAdmin, loadEnterpriseUsage, loadPersonalUsage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshEnterpriseUsage();
|
void refreshEnterpriseUsage();
|
||||||
@@ -241,9 +233,6 @@ function TokenUsagePage({
|
|||||||
</button>
|
</button>
|
||||||
<strong>管理中心</strong>
|
<strong>管理中心</strong>
|
||||||
</div>
|
</div>
|
||||||
<span className="management-center-status-pill">
|
|
||||||
{enterpriseUsageLoading ? "正在同步企业用量" : enterpriseUsageError || "服务器已连接"}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={refreshEnterpriseUsage}>
|
<button type="button" onClick={refreshEnterpriseUsage}>
|
||||||
<ReloadOutlined />
|
<ReloadOutlined />
|
||||||
刷新数据
|
刷新数据
|
||||||
@@ -252,17 +241,12 @@ 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}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ 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";
|
||||||
@@ -238,6 +239,7 @@ function WorkbenchPage({
|
|||||||
const lastScrollTopRef = useRef(0);
|
const lastScrollTopRef = useRef(0);
|
||||||
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);
|
||||||
|
|
||||||
@@ -1851,6 +1853,7 @@ 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,
|
||||||
@@ -1870,6 +1873,7 @@ 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,8 @@ 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';
|
||||||
|
|||||||
@@ -365,11 +365,113 @@
|
|||||||
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) {
|
||||||
|
|||||||
@@ -189,6 +189,40 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -725,9 +725,110 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -786,4 +887,9 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compliance-hero,
|
||||||
|
.compliance-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2809,6 +2809,26 @@
|
|||||||
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);
|
||||||
@@ -7933,3 +7953,31 @@
|
|||||||
.clone-ai-adwizard__risk.is-medium { color: #faad14; }
|
.clone-ai-adwizard__risk.is-medium { color: #faad14; }
|
||||||
.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -316,7 +316,8 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: clamp(214px, 17vw, 286px);
|
width: clamp(214px, 17vw, 286px);
|
||||||
height: clamp(122px, 9.8vw, 162px);
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -388,7 +389,8 @@
|
|||||||
|
|
||||||
.omni-home__carousel-card.is-active {
|
.omni-home__carousel-card.is-active {
|
||||||
width: clamp(390px, 37vw, 620px);
|
width: clamp(390px, 37vw, 620px);
|
||||||
height: clamp(220px, 20.8vw, 350px);
|
height: auto;
|
||||||
|
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%),
|
||||||
|
|||||||
@@ -826,3 +826,149 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3782,21 +3782,12 @@
|
|||||||
.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: #202020;
|
background: var(--bg-inset, transparent);
|
||||||
color: rgba(255, 255, 255, 0.22);
|
color: rgba(255, 255, 255, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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: 34px;
|
font-size: 40px;
|
||||||
}
|
|
||||||
|
|
||||||
.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,
|
||||||
@@ -3812,18 +3803,35 @@
|
|||||||
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: none;
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,6 @@ export type WebViewKey =
|
|||||||
| "sizeTemplate"
|
| "sizeTemplate"
|
||||||
| "scriptTokens"
|
| "scriptTokens"
|
||||||
| "tokenUsage"
|
| "tokenUsage"
|
||||||
| "settings"
|
|
||||||
| "imageWorkbench"
|
| "imageWorkbench"
|
||||||
| "resolutionUpscale"
|
| "resolutionUpscale"
|
||||||
| "digitalHuman"
|
| "digitalHuman"
|
||||||
@@ -27,6 +26,8 @@ export type WebViewKey =
|
|||||||
| "communityCaseAdd"
|
| "communityCaseAdd"
|
||||||
| "report"
|
| "report"
|
||||||
| "providerHealth"
|
| "providerHealth"
|
||||||
|
| "userAgreement"
|
||||||
|
| "privacyPolicy"
|
||||||
| "not-found";
|
| "not-found";
|
||||||
|
|
||||||
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
|
export type WebImageWorkbenchTool = "workbench" | "inpaint" | "camera";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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;
|
||||||
@@ -44,6 +45,8 @@ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user