Compare commits

...

19 Commits

Author SHA1 Message Date
stringadmin 6cc81e3804 Improve generation task client errors 2026-06-04 21:07:48 +08:00
stringadmin c1c4086383 Merge pull request 'fix: ????????? OSS ????' (#13) from fix/ecommerce-video-400-bug into master
Reviewed-on: #13
2026-06-04 12:10:38 +00:00
stringadmin 3493f169c0 fix: use public model config and disable source maps 2026-06-04 19:00:50 +08:00
stringadmin b81128d7ca fix: harden ecommerce media history for launch 2026-06-04 18:27:12 +08:00
stringadmin e166722945 Merge pull request 'Feat/commercial saas polish' (#12) from feat/commercial-saas-polish into master
Reviewed-on: #12
2026-06-04 10:09:15 +00:00
ludan e8a42dafde fix: 修复合并冲突导致的CSS未闭合花括号 2026-06-04 16:29:38 +08:00
ludan c4ef9cc6ba Merge origin/master: resolve CSS conflicts
script-tokens-v5.css: keep both SaaS polish rules and master's additions
dark-green.css: keep both profile/canvas polish and master's additions
2026-06-04 16:23:43 +08:00
ludan 05a42ed018 Merge origin/master: resolve CSS conflicts, keep both sides 2026-06-04 16:22:40 +08:00
ludan 9e7bfdd206 Merge origin/master into feat/commercial-saas-polish 2026-06-04 16:07:39 +08:00
stringadmin 20e219732d Merge pull request 'Fix/ecommerce video 400 bug' (#11) from fix/ecommerce-video-400-bug into master
Reviewed-on: #11
2026-06-04 08:05:20 +00:00
stringadmin c7c52c1467 chore: migrate frontend assets to OSS and same-origin APIs 2026-06-04 16:03:49 +08:00
ludan fb4011bf1f feat: 个人中心视觉重构、画布网点背景、剧本评分色调统一
【个人中心视觉重构】
- 列表卡片新增媒体预览缩略图(图片/视频/项目/资产),支持 image/video 两种媒体类型
- 新增 renderCardPreview 通用预览组件,自动识别视频格式并渲染 <video> 标签
- 新增 formatAssetType 工具函数,资产类型中文化(角色/场景/道具/视频/图像/素材)
- 媒体卡片采用固定高度网格布局(标题行 18px/正文 36px/元信息 18px),保证列表节奏一致
- 卡片预览区左上角显示类型标签徽章(品牌绿边框+半透明背景)
- 删除按钮增加 hover 红色反馈(边框/背景/文字渐变至红色)
- 积分/任务面板从底部区域移至侧边栏头像下方,减少滚动距离
- 新增 account-card 容器包裹积分/任务切换面板
- 侧边栏统计数据改为 3 列网格布局,每项增加独立圆角卡片样式
- 作品/项目/资产/社区发布四个 Tab 改为均分 4 列网格
- 分区标题增加品牌绿圆点前缀装饰
- 响应式断点:960px(侧边栏双列+内容区单列)、640px(全部单列+标签横向滚动)、420px(紧凑间距)

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

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

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

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

【Token 用量页精简】
- 移除指标卡片序号角标,保持卡片视觉简洁
2026-06-04 09:40:28 +08:00
stringadmin 7c6129555b fix: 修复多个运行时崩溃和功能bug,优化画布连接线和剧本评分
- 修复 EcommercePage generateEcommerceImage 调用不存在变量导致运行时崩溃
- 修复 DigitalHumanPage/ImageWorkbenchPage 变量名错误导致页面不可用
- 修复 ecommerceVideoService token 读取用错 key 导致请求 401
- 修复画布连接线在弹窗出现后仍跟随鼠标的问题
- 剧本评分 .docx 文件改为服务端 mammoth 解析(新增 /api/files/extract-text)
- ErrorBoundary 加 key 支持切换页面时自动重置
- Vite proxy 改为指向公网域名 omniai.net.cn
- 新增视频生成历史记录面板和删除确认弹窗

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 01:12:51 +08:00
stringadmin 6bb71fcc19 feat: 视频时长滑块支持最大 45 秒,适配广告视频多分镜场景
单次视频模型最高 15s,45s 总时长配 3 个分镜刚好每个 15s。
滑块 step 改为 5s,刻度标注 5/15/30/45 秒。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:55:15 +08:00
stringadmin 7993435704 fix: 修复视频生成 API 调用链路,确保参考图正确传递到各阶段
- 生图阶段:将用户上传的产品图作为 referenceUrls 传给生图模型
- 生视频阶段:同时传递用户原图 + 分镜图给视频模型
- 完整链路:参考图→视觉分析→分镜文本→(文本+原图)→分镜图→(文本+原图+分镜图)→视频

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:52:25 +08:00
stringadmin 31bf103d7c feat: 视频流程树动态节点、全自动流水线、图片/视频点击放大预览
- 一键策划后自动连续执行完整流程(策划→图片→视频),无需手动点继续
- 节点数量跟随 API 返回的分镜数动态生成,策划前只显示 1 个占位节点
- 分镜图片和视频可点击弹出全屏预览浮层

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:43:34 +08:00
stringadmin bf401e4ab0 fix: 视频流程树节点放大并修复分支线与节点的对齐错位
将 branch-tap 改为 flex:1 布局自动对齐行中心,
放大节点尺寸和间距使流程图在画布中更易阅读。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:36:24 +08:00
stringadmin 7e631cfa1b fix: 电商页面 KeepAlive 容器使用绝对定位铺满视口,解决底部留白问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 23:26:54 +08:00
72 changed files with 5741 additions and 684 deletions
+5 -8
View File
@@ -1,8 +1,5 @@
# Dev proxy target — the backend API server # Frontend environment variables are intentionally unsupported.
VITE_DEV_PROXY=http://47.110.225.76:3600 #
# API traffic must go through same-origin /api.
# Key server URL for auth/profile endpoints # Public runtime settings must come from application APIs.
VITE_KEY_SERVER_URL= # Provider keys and OSS credentials must stay on the server.
# Main API base URL (used when not served from omniai.net.cn)
VITE_API_BASE_URL=
+2
View File
@@ -10,6 +10,8 @@ node_modules/
Thumbs.db Thumbs.db
.vscode/ .vscode/
.idea/ .idea/
.claude/
tmp/
*.swp *.swp
*.swo *.swo
coverage/ coverage/
+39
View File
@@ -0,0 +1,39 @@
# Project Rules
## Asset, Key, And Runtime Data Governance
These rules are mandatory for all frontend, backend, deployment, and agent-generated changes.
1. Image and media assets must be stored in OSS.
- Do not commit product images, demo images, generated images, videos, or other large media assets into `src/assets` or other source folders.
- Code may reference media only by OSS URL or by data returned from an API.
- Local assets are limited to tiny build-critical files such as icons or placeholders, and require explicit justification.
2. Frontend code must not contain API keys or secrets.
- Do not hard-code provider keys, access keys, tokens, private endpoints, passwords, or bearer tokens in TypeScript, CSS, HTML, Vite config, Nginx snippets, or checked-in docs.
- Browser-delivered code must treat every visible value as public.
3. Provider keys are owned by the server key pool.
- AI provider credentials are stored and managed server-side.
- The frontend requests work through application APIs; the server leases provider keys from the concurrency/key pool and calls providers on behalf of the client.
- Do not add direct browser-to-provider calls that require provider credentials.
4. Application data must come through APIs.
- Do not hard-code product data, pricing, model availability, provider routing, account state, usage state, or operational configuration in the frontend.
- Use typed API clients and server-provided payloads for runtime data.
- Static constants are allowed only for presentation defaults that are not business-authoritative.
5. Do not use fixed environment configuration in application code.
- Do not bake production hostnames, provider endpoints, keys, or environment-specific behavior into source code.
- Environment-specific values belong in server deployment configuration, secret management, or runtime configuration endpoints.
- Frontend code must not add fixed `VITE_*` or equivalent environment variables for API hosts, provider hosts, business data, or secrets.
- If the browser needs runtime configuration, it must request that data from an application API.
6. Deployment configuration must follow the same rules.
- Nginx and process manager configs must not embed provider API keys or long-lived credentials.
- Reverse proxies should route application traffic to the backend, not expose third-party credentials.
- Secrets must be rotated immediately if found in source, Git remotes, shell history, Nginx config, process manager config, or logs.
7. Reviews must reject violations.
- Any new local media file, hard-coded key, direct provider credential path, or fixed production config is a blocking issue.
- Prefer deleting local assets and replacing them with OSS URLs returned by APIs or server-managed config.
+1
View File
@@ -8,6 +8,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 127.0.0.1", "preview": "vite preview --host 127.0.0.1",
"type-check": "tsc -p tsconfig.json --noEmit", "type-check": "tsc -p tsconfig.json --noEmit",
"governance:check": "node scripts/check-governance.mjs",
"style:check": "node scripts/check-style-governance.mjs", "style:check": "node scripts/check-style-governance.mjs",
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs" "smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
}, },
+80
View File
@@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]);
const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]);
const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"];
const allowedFiles = new Set([
normalizePath("src/data/ossAssets.ts"),
normalizePath("src/utils/ossImageOptimize.ts"),
]);
const forbiddenPatterns = [
{ label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ },
{ label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i },
{ label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i },
{ label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i },
{ label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i },
];
const failures = [];
function normalizePath(value) {
return value.replace(/\\/g, "/");
}
function walk(targetPath, visitor) {
if (!fs.existsSync(targetPath)) return;
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(targetPath)) {
if (entry === "node_modules" || entry === "dist" || entry === ".git") continue;
walk(path.join(targetPath, entry), visitor);
}
return;
}
visitor(targetPath, stat);
}
function report(file, message) {
failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`);
}
walk(path.join(repoRoot, "src", "assets"), (file) => {
if (mediaExtensions.has(path.extname(file).toLowerCase())) {
report(file, "media files must live in OSS, not src/assets");
}
});
for (const root of scanRoots) {
walk(path.join(repoRoot, root), (file) => {
const relative = normalizePath(path.relative(repoRoot, file));
const ext = path.extname(file).toLowerCase();
if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return;
if (relative.startsWith("src/assets/")) return;
const content = fs.readFileSync(file, "utf8");
const isAllowed = allowedFiles.has(relative);
for (const rule of forbiddenPatterns) {
if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) {
continue;
}
if (rule.pattern.test(content)) {
report(file, `forbidden ${rule.label}`);
}
}
});
}
if (failures.length) {
console.error("Governance check failed:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log("Governance check passed.");
+1
View File
@@ -0,0 +1 @@
import "./check-governance.mjs";
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
const failures = [];
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function assertMatch(label, content, pattern) {
if (!pattern.test(content)) {
failures.push(label);
}
}
function assertNoMatch(label, content, pattern) {
if (pattern.test(content)) {
failures.push(label);
}
}
const serverConnection = read("src/api/serverConnection.ts");
const generationClient = read("src/api/aiGenerationClient.ts");
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
assertMatch(
"serverConnection must build same-origin /api URLs",
serverConnection,
/return\s+`\/api\/\$\{cleanPath\}`;/,
);
assertNoMatch(
"frontend generation flow must not use fixed VITE environment config",
`${serverConnection}\n${generationClient}`,
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
);
assertNoMatch(
"frontend generation flow must not call provider hosts directly",
generationClient,
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
);
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
assertMatch(
"ecommerce video history must durable-copy media before saving",
ecommerceVideoService,
/buildDurableVideoHistoryPayload\(payload\)/,
);
assertMatch(
"ecommerce video history must filter temporary provider URLs on read",
ecommerceVideoService,
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
);
assertMatch(
"workbench results must persist generated media through OSS",
workbenchPersistence,
/uploadAssetByUrl\(/,
);
if (failures.length) {
console.error("Mocked generation smoke check failed:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log("Mocked generation smoke check passed.");
+12 -7
View File
@@ -28,6 +28,7 @@ import {
SERVER_SESSION_REPLACED_EVENT, SERVER_SESSION_REPLACED_EVENT,
SERVER_SESSION_EXPIRED_EVENT, SERVER_SESSION_EXPIRED_EVENT,
checkServerHealth, checkServerHealth,
clearAllUserStorage,
getErrorMessage, getErrorMessage,
type ServerSessionReplacedDetail, type ServerSessionReplacedDetail,
} from "./api/serverConnection"; } from "./api/serverConnection";
@@ -143,7 +144,9 @@ function normalizeViewKey(rawView: string): WebViewKey {
} }
function readViewFromHash(): WebViewKey { function readViewFromHash(): WebViewKey {
return normalizeViewKey(window.location.hash.replace(/^#\/?/, "")); const raw = window.location.hash.replace(/^#\/?/, "");
if (!raw) return "home";
return normalizeViewKey(raw);
} }
function isWorkspaceView(view: WebViewKey): boolean { function isWorkspaceView(view: WebViewKey): boolean {
@@ -375,7 +378,7 @@ function App() {
}, [setView, setWorkspaceExpanded]); }, [setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
keyServerClient.clearSession(); clearAllUserStorage();
clearSessionState(); clearSessionState();
setProjects([]); setProjects([]);
setProjectsLoaded(true); setProjectsLoaded(true);
@@ -1224,7 +1227,7 @@ function App() {
onMarkNotificationRead={handleMarkNotificationRead} onMarkNotificationRead={handleMarkNotificationRead}
onMarkAllNotificationsRead={handleMarkAllNotificationsRead} onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
> >
<ErrorBoundary> <ErrorBoundary key={activeView}>
<Suspense fallback={ <Suspense fallback={
<div className="page-loading-center"> <div className="page-loading-center">
<div className="page-loading-spinner" /> <div className="page-loading-spinner" />
@@ -1234,10 +1237,13 @@ function App() {
<PageTransition viewKey={activeView}> <PageTransition viewKey={activeView}>
{activePage} {activePage}
</PageTransition> </PageTransition>
</Suspense>
</ErrorBoundary>
{/* KeepAlive: EcommercePage stays mounted once visited */} {/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
{ecommerceEverMounted && ( {ecommerceEverMounted && (
<div style={{ display: isEcommerceActive ? undefined : "none" }}> <div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
<Suspense fallback={null}>
<EcommercePage <EcommercePage
projects={projects} projects={projects}
isAuthenticated={Boolean(session)} isAuthenticated={Boolean(session)}
@@ -1250,10 +1256,9 @@ function App() {
initialTemplate={pendingEcommerceTemplate} initialTemplate={pendingEcommerceTemplate}
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)} onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/> />
</Suspense>
</div> </div>
)} )}
</Suspense>
</ErrorBoundary>
{loginPromptOpen && pendingAction ? ( {loginPromptOpen && pendingAction ? (
<div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title"> <div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title">
+2 -2
View File
@@ -159,7 +159,7 @@ function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPrevi
function taskTitle(task: AiTaskStatus): string { function taskTitle(task: AiTaskStatus): string {
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : ""; const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
return task.type === "video" ? "视频生成任务" : "图像生成任务"; return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
} }
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask { function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
@@ -512,7 +512,7 @@ export const aiGenerationClient = {
} }
const reader = res.body?.getReader(); const reader = res.body?.getReader();
if (!reader) throw new Error("无法读取响应流"); if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
+2 -2
View File
@@ -913,7 +913,7 @@ export const keyServerClient = {
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> { async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
const stored = readStoredSession(); const stored = readStoredSession();
if (!stored) { if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍"); throw new Error("需要先登录");
} }
const safeProjectId = encodeURIComponent(projectId.trim()); const safeProjectId = encodeURIComponent(projectId.trim());
@@ -1000,7 +1000,7 @@ export const keyServerClient = {
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> { async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
const stored = readStoredSession(); const stored = readStoredSession();
if (!stored) { if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍"); throw new Error("需要先登录");
} }
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
+1 -2
View File
@@ -67,12 +67,11 @@ let modelCapabilitiesRouteMissing = false;
export const modelCapabilitiesClient = { export const modelCapabilitiesClient = {
async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> { async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> {
if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities();
if (modelCapabilitiesRouteMissing) return createFallbackCapabilities(); if (modelCapabilitiesRouteMissing) return createFallbackCapabilities();
let payload: unknown; let payload: unknown;
try { try {
payload = await serverRequest<unknown>(`config/profile?name=${encodeURIComponent(name)}`); payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
} catch (error) { } catch (error) {
if (isOptionalApiRouteMissing(error)) { if (isOptionalApiRouteMissing(error)) {
modelCapabilitiesRouteMissing = true; modelCapabilitiesRouteMissing = true;
+51
View File
@@ -0,0 +1,51 @@
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
import { isRecord, serverRequest } from "./serverConnection";
export interface WebPublicConfig {
contactEmail?: string;
contactPhone?: string;
companyAddress?: string;
icpRecord?: string;
}
function readString(config: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = config[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function normalizePublicConfig(raw: unknown): WebPublicConfig {
const config = isRecord(raw) && isRecord(raw.config) ? raw.config : raw;
if (!isRecord(config)) return {};
return {
contactEmail: readString(config, ["contactEmail", "contact_email", "supportEmail", "support_email"]),
contactPhone: readString(config, ["contactPhone", "contact_phone", "supportPhone", "support_phone"]),
companyAddress: readString(config, ["companyAddress", "company_address", "address"]),
icpRecord: readString(config, ["icpRecord", "icp_record", "filingInfo", "filing_info"]),
};
}
let cachedPublicConfig: WebPublicConfig | null = null;
let publicConfigRouteMissing = false;
export const publicConfigClient = {
async get(): Promise<WebPublicConfig> {
if (cachedPublicConfig) return cachedPublicConfig;
if (publicConfigRouteMissing) return {};
try {
const payload = await serverRequest<unknown>("public/config/profile?name=web-public-config");
cachedPublicConfig = normalizePublicConfig(payload);
return cachedPublicConfig;
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
publicConfigRouteMissing = true;
return {};
}
throw error;
}
},
};
+47 -24
View File
@@ -1,6 +1,5 @@
import type { WebUserSession } from "../types"; import type { WebUserSession } from "../types";
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session"; export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced"; export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired"; export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
@@ -59,34 +58,12 @@ export function compactMessage(value: string): string {
} }
export function getServerBaseUrl(): string { export function getServerBaseUrl(): string {
const envBaseUrl = String(
import.meta.env.VITE_KEY_SERVER_URL ||
import.meta.env.VITE_SERVER_BASE_URL ||
import.meta.env.VITE_API_BASE_URL ||
"",
).trim();
const shouldUseSameOriginApi =
typeof window !== "undefined" &&
(window.location.protocol === "https:" ||
window.location.hostname === "omniai.net.cn" ||
window.location.hostname === "www.omniai.net.cn");
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
return ""; return "";
} }
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
}
export function buildApiUrl(path: string): string { export function buildApiUrl(path: string): string {
const cleanPath = path.replace(/^\/+/, ""); const cleanPath = path.replace(/^\/+/, "");
const baseUrl = getServerBaseUrl(); return `/api/${cleanPath}`;
if (!baseUrl) return `/api/${cleanPath}`;
try {
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
} catch {
return `${baseUrl}/api/${cleanPath}`;
}
} }
export function canUseSessionStorage(): boolean { export function canUseSessionStorage(): boolean {
@@ -167,6 +144,39 @@ export function writeStoredSession(session: WebUserSession | null): void {
} }
} }
export function clearAllUserStorage(): void {
writeStoredSession(null);
try {
if (typeof window === "undefined") return;
const legacyKeys = ["omniai:token", "omniai:session"];
for (const key of legacyKeys) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
const prefixKeys = [
"omniai-web-profile-ui",
"omniai:more-recent-tools",
"omniai:generation-queue",
"omniai-canvas-saved-assets",
];
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.localStorage.removeItem(key);
}
}
for (let i = window.sessionStorage.length - 1; i >= 0; i--) {
const key = window.sessionStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.sessionStorage.removeItem(key);
}
}
} catch {
// best-effort cleanup
}
}
export function getStoredToken(): string | null { export function getStoredToken(): string | null {
return readStoredSession()?.token ?? null; return readStoredSession()?.token ?? null;
} }
@@ -226,6 +236,15 @@ let lastSessionReplacedEventAt = 0;
let lastSessionExpiredEventAt = 0; let lastSessionExpiredEventAt = 0;
function isNonAuthErrorCode(code: string | undefined): boolean {
if (!code) return false;
return [
"ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED",
"INSUFFICIENT_BALANCE",
"INSUFFICIENT_ENTERPRISE_BALANCE",
].includes(code);
}
function notifySessionExpired(status: number, response: Response, payload: unknown): void { function notifySessionExpired(status: number, response: Response, payload: unknown): void {
if (status !== 401 && status !== 403) return; if (status !== 401 && status !== 403) return;
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -238,6 +257,9 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
if (!readStoredSession()) return; if (!readStoredSession()) return;
// Deliberate early-exit for unauthenticated users — not a real auth failure. // Deliberate early-exit for unauthenticated users — not a real auth failure.
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return; if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
// Non-auth 403 errors (enterprise model access, insufficient balance) must
// not trigger session expiry.
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
const now = Date.now(); const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return; if (now - lastSessionExpiredEventAt < 1500) return;
@@ -341,6 +363,7 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
headers, headers,
body: options?.body === undefined ? undefined : JSON.stringify(options.body), body: options?.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller ? controller.signal : options?.signal, signal: controller ? controller.signal : options?.signal,
credentials: "include",
}); });
const payload = await readJsonResponse<unknown>(response, "Request failed"); const payload = await readJsonResponse<unknown>(response, "Request failed");
+5 -4
View File
@@ -54,15 +54,13 @@ export function waitForTask(
if (event.status === "completed") { if (event.status === "completed") {
settle(() => resolve(event.resultUrl || null)); settle(() => resolve(event.resultUrl || null));
} else if (event.status === "failed" || event.status === "cancelled") { } else if (event.status === "failed" || event.status === "cancelled") {
settle(() => reject(new Error(event.error || "任务失败"))); settle(() => reject(new Error(event.error || "任务失败,请稍后重试")));
} }
}; };
// Try SSE first
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
sseConnected = true; sseConnected = true;
// Fallback: if SSE doesn't deliver any event within 5s, switch to polling
fallbackTimerId = setTimeout(() => { fallbackTimerId = setTimeout(() => {
if (settled || !sseConnected) return; if (settled || !sseConnected) return;
if (cleanup) cleanup(); if (cleanup) cleanup();
@@ -72,7 +70,10 @@ export function waitForTask(
function startPolling() { function startPolling() {
const poll = async () => { const poll = async () => {
while (!settled) { while (!settled) {
if (abortRef?.current) { settle(() => resolve(null)); return; } if (abortRef?.current) {
settle(() => resolve(null));
return;
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL)); await new Promise((r) => setTimeout(r, POLL_INTERVAL));
if (settled || abortRef?.current) return; if (settled || abortRef?.current) return;
try { try {
+1 -1
View File
@@ -103,7 +103,7 @@ export const webGenerationGateway = {
prompt, prompt,
createdAt, createdAt,
source: "server", source: "server",
errorMessage: err instanceof Error ? err.message : "请求失败", errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
}; };
} }
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 MiB

+25 -11
View File
@@ -6,16 +6,15 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
LoginOutlined, LoginOutlined,
LogoutOutlined, LogoutOutlined,
PhoneOutlined,
SafetyOutlined,
EnvironmentOutlined,
PlusCircleOutlined, PlusCircleOutlined,
UserOutlined, UserOutlined,
WalletOutlined, WalletOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import type { ServerConnectionHealth } from "../api/serverConnection"; import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
import NotificationCenter from "./NotificationCenter"; import NotificationCenter from "./NotificationCenter";
@@ -40,8 +39,7 @@ interface AppShellProps {
children: ReactNode; children: ReactNode;
} }
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; const BRAND_LOGO_URL = ossAssets.brand.logo;
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;
@@ -71,6 +69,7 @@ function AppShell({
const [infoOpen, setInfoOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false);
const infoRef = useRef<HTMLDivElement>(null); const infoRef = useRef<HTMLDivElement>(null);
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null); const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
const prevActiveViewRef = useRef<WebViewKey>(activeView); const prevActiveViewRef = useRef<WebViewKey>(activeView);
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null); const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login"; const isAuthView = activeView === "login";
@@ -136,6 +135,22 @@ function AppShell({
} }
}, []); }, []);
useEffect(() => {
let cancelled = false;
publicConfigClient
.get()
.then((config) => {
if (!cancelled) setPublicConfig(config);
})
.catch(() => {
if (!cancelled) setPublicConfig({});
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
if (!profileOpen) return; if (!profileOpen) return;
@@ -220,7 +235,6 @@ function AppShell({
? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents) ? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents)
: usage.balanceCents; : usage.balanceCents;
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分"; const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
const isPreviewSession = session?.source === "mock-fallback";
const showCommunityReview = canReviewCommunity(session); const showCommunityReview = canReviewCommunity(session);
const showCommunityCaseAdd = canManageCommunityCases(session); const showCommunityCaseAdd = canManageCommunityCases(session);
@@ -339,11 +353,11 @@ function AppShell({
<AnimatedPanel open={infoOpen} className="info-popover panel-surface"> <AnimatedPanel open={infoOpen} className="info-popover panel-surface">
<dl> <dl>
<dt></dt> <dt></dt>
<dd>ICP备2026021747号-1</dd> <dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
<dt></dt> <dt></dt>
<dd>9A楼501</dd> <dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
<dt></dt> <dt></dt>
<dd>15155073618</dd> <dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
</dl> </dl>
<div className="info-popover__links"> <div className="info-popover__links">
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}></a> <a href="#/userAgreement" onClick={() => setInfoOpen(false)}></a>
@@ -407,7 +421,7 @@ function AppShell({
<dd>{usage.videoUsed}</dd> <dd>{usage.videoUsed}</dd>
</dl> </dl>
<div className="profile-popover__footer"> <div className="profile-popover__footer">
<span>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span> <span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
<button type="button" onClick={onLogout}> <button type="button" onClick={onLogout}>
<LogoutOutlined /> <LogoutOutlined />
退 退
@@ -473,7 +487,7 @@ function AppShell({
<div className="web-shell__page">{children}</div> <div className="web-shell__page">{children}</div>
</main> </main>
</div> </div>
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null} {session?.user.role === "admin" ? <AdminMonitor /> : null}
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> <RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
<CookieConsentBanner /> <CookieConsentBanner />
</div> </div>
+124
View File
@@ -0,0 +1,124 @@
const OSS_PUBLIC_BASE_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com";
function oss(path: string): string {
return `${OSS_PUBLIC_BASE_URL}/${path.replace(/^\/+/, "")}`;
}
function muban(path: string): string {
return oss(`muban/${path.replace(/^\/+/, "")}`);
}
function toolbox(path: string): string {
return oss(`static/toolbox/${path.replace(/^\/+/, "")}`);
}
export const ossAssets = {
brand: {
logo: oss("logo.png"),
},
auth: {
showcaseVideo: oss("test5.mp4"),
},
home: {
backgroundVideo: muban("hero-bg.mp4"),
heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
features: {
ecommerce: muban("feature-ecommerce.jpg"),
script: muban("feature-script.jpg"),
token: muban("feature-token.jpg"),
},
},
toolbox: {
imageBefore: toolbox("%E7%89%9B%E4%BB%94.webp"),
imageAfter: toolbox("%E8%A5%BF%E8%A3%85.webp"),
watermarkBefore: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%89%8D.webp"),
watermarkAfter: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%90%8E.webp"),
},
community: {
cardImages: [
muban("dianshang1.png"),
muban("dianshang2.png"),
muban("dianshang3.png"),
muban("wechat-7.png"),
muban("wechat-8.png"),
muban("wechat-9.png"),
],
carouselVideos: [oss("test3.mp4"), oss("test4.mp4"), oss("test6.mp4")],
},
workflows: {
caseImages: [
muban("community/workflow-rain-night.jpg"),
muban("community/workflow-character-look.jpg"),
muban("community/workflow-skyline.jpg"),
muban("community/workflow-lab.jpg"),
],
},
ecommerce: {
generated: muban("ecommerce-carousel-generated.png"),
slides: {
slide4: muban("slide-4.png"),
slide5: muban("slide-5.png"),
},
heroSlides: [
muban("ecommerce-hero-carousel/slide-1.webp"),
muban("ecommerce-hero-carousel/slide-2.webp"),
muban("ecommerce-hero-carousel/slide-3.webp"),
muban("ecommerce-hero-carousel/slide-4.webp"),
muban("ecommerce-hero-carousel/slide-5.webp"),
],
templateSlides: [
muban("more-template-carousel/slide-1.jpg"),
muban("more-template-carousel/slide-2.jpg"),
muban("more-template-carousel/slide-3.jpg"),
muban("more-template-carousel/slide-4.png"),
muban("more-template-carousel/slide-5.gif"),
],
templateCases: [
muban("ecommerce/templates/case-1.png"),
muban("ecommerce/templates/case-2.png"),
muban("ecommerce/templates/case-3.png"),
muban("ecommerce/templates/case-4.png"),
muban("ecommerce/templates/case-5.png"),
muban("ecommerce/templates/case-6.png"),
],
productSet: {
main: muban("ecommerce/product-set/main.webp"),
scene: muban("ecommerce/product-set/scene.webp"),
model: muban("ecommerce/product-set/model.webp"),
detail: muban("ecommerce/product-set/detail.webp"),
selling: muban("ecommerce/product-set/selling.webp"),
hosting: muban("ecommerce/product-set/hosting.webp"),
},
tryOn: {
dressA: muban("ecommerce/try-on/dress-a.webp"),
dressB: muban("ecommerce/try-on/dress-b.webp"),
modelWoman: muban("ecommerce/try-on/model-woman.webp"),
modelMan: muban("ecommerce/try-on/model-man.webp"),
modelAsian: muban("ecommerce/try-on/model-asian.webp"),
tryA: muban("ecommerce/try-on/result-a.webp"),
tryB: muban("ecommerce/try-on/result-b.webp"),
jacket: muban("ecommerce/try-on/jacket.webp"),
jacketResultA: muban("ecommerce/try-on/jacket-result-a.webp"),
jacketResultB: muban("ecommerce/try-on/jacket-result-b.webp"),
hat: muban("ecommerce/try-on/hat.webp"),
hatResultA: muban("ecommerce/try-on/hat-result-a.webp"),
hatResultB: muban("ecommerce/try-on/hat-result-b.webp"),
},
detail: {
productA: muban("ecommerce/detail/product-a.webp"),
productB: muban("ecommerce/detail/product-b.webp"),
productC: muban("ecommerce/detail/product-c.webp"),
longPage: muban("ecommerce/detail/long-page.webp"),
gridA: muban("ecommerce/detail/grid-a.webp"),
gridB: muban("ecommerce/detail/grid-b.webp"),
gridC: muban("ecommerce/detail/grid-c.webp"),
gridD: muban("ecommerce/detail/grid-d.webp"),
gridE: muban("ecommerce/detail/grid-e.webp"),
gridF: muban("ecommerce/detail/grid-f.webp"),
},
},
} as const;
export type ProductSetOssAssets = typeof ossAssets.ecommerce.productSet;
export type TryOnOssAssets = typeof ossAssets.ecommerce.tryOn;
export type DetailOssAssets = typeof ossAssets.ecommerce.detail;
+11 -8
View File
@@ -1,4 +1,7 @@
import type { WebCanvasWorkflow, WebCommunityCase } from "../types"; import type { WebCanvasWorkflow, WebCommunityCase } from "../types";
import { ossAssets } from "./ossAssets";
const [rainNightImage, characterLookImage, skylineImage, labImage] = ossAssets.workflows.caseImages;
function createNodes( function createNodes(
title: string, title: string,
@@ -69,7 +72,7 @@ export const communityCases: WebCommunityCase[] = [
author: "Dave", author: "Dave",
tag: "视频案例", tag: "视频案例",
summary: "从街口推到人物面部,强调雨夜反光与情绪收束。", summary: "从街口推到人物面部,强调雨夜反光与情绪收束。",
imageUrl: "https://picsum.photos/id/1011/900/540", imageUrl: rainNightImage,
workflow: { workflow: {
id: "workflow-rain-night", id: "workflow-rain-night",
version: 1, version: 1,
@@ -83,7 +86,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "6s", duration: "6s",
resolution: "720p", resolution: "720p",
}, },
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", "https://picsum.photos/id/1011/960/540"), nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", rainNightImage),
edges: createEdges(), edges: createEdges(),
}, },
}, },
@@ -93,7 +96,7 @@ export const communityCases: WebCommunityCase[] = [
author: "SuperXe", author: "SuperXe",
tag: "角色案例", tag: "角色案例",
summary: "把单张角色图扩展成可连续出片的角色工作流。", summary: "把单张角色图扩展成可连续出片的角色工作流。",
imageUrl: "https://picsum.photos/id/1027/900/540", imageUrl: characterLookImage,
workflow: { workflow: {
id: "workflow-character-look", id: "workflow-character-look",
version: 1, version: 1,
@@ -107,7 +110,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "5s", duration: "5s",
resolution: "720p", resolution: "720p",
}, },
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", "https://picsum.photos/id/1027/960/540"), nodes: createNodes("角色定妆,强调服装、姿态与近景表情", characterLookImage),
edges: createEdges(), edges: createEdges(),
}, },
}, },
@@ -117,7 +120,7 @@ export const communityCases: WebCommunityCase[] = [
author: "OmniAI", author: "OmniAI",
tag: "风景案例", tag: "风景案例",
summary: "用广角风景做镜头进入,适合转场和开场片头。", summary: "用广角风景做镜头进入,适合转场和开场片头。",
imageUrl: "https://picsum.photos/id/1050/900/540", imageUrl: skylineImage,
workflow: { workflow: {
id: "workflow-skyline", id: "workflow-skyline",
version: 1, version: 1,
@@ -131,7 +134,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "8s", duration: "8s",
resolution: "1080p", resolution: "1080p",
}, },
nodes: createNodes("风景开场,镜头缓慢推进到天际线", "https://picsum.photos/id/1050/960/540"), nodes: createNodes("风景开场,镜头缓慢推进到天际线", skylineImage),
edges: createEdges(), edges: createEdges(),
}, },
}, },
@@ -141,7 +144,7 @@ export const communityCases: WebCommunityCase[] = [
author: "Studio", author: "Studio",
tag: "实验案例", tag: "实验案例",
summary: "更适合拆解推拉摇移和节奏控制的实验模板。", summary: "更适合拆解推拉摇移和节奏控制的实验模板。",
imageUrl: "https://picsum.photos/id/1056/900/540", imageUrl: labImage,
workflow: { workflow: {
id: "workflow-lab", id: "workflow-lab",
version: 1, version: 1,
@@ -155,7 +158,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "6s", duration: "6s",
resolution: "720p", resolution: "720p",
}, },
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", "https://picsum.photos/id/1056/960/540"), nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", labImage),
edges: createEdges(), edges: createEdges(),
}, },
}, },
+22 -11
View File
@@ -26,7 +26,6 @@
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
Background,
ReactFlow, ReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
@@ -2646,6 +2645,22 @@ function CanvasPage({
} }
: null; : null;
})() })()
: connectionDropMenu
? (() => {
const source = getNodePortPoint(connectionDropMenu.sourcePort);
const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
return source
? {
id: "pending-link-preview",
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
sourceSide: connectionDropMenu.sourcePort.side,
targetSide: null,
}
: null;
})()
: null; : null;
const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => { const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
@@ -2816,6 +2831,8 @@ function CanvasPage({
originTop: event.clientY, originTop: event.clientY,
sourcePort: connectorDrag.port, sourcePort: connectorDrag.port,
}); });
setPendingLinkPort(null);
setPendingLinkPreviewPoint(null);
} }
} else { } else {
clearPendingConnector(); clearPendingConnector();
@@ -2840,7 +2857,7 @@ function CanvasPage({
}, [selectedNode]); }, [selectedNode]);
const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => { const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
if (!pendingLinkPort) return; if (!pendingLinkPort || connectionDropMenu) return;
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
}; };
@@ -3542,7 +3559,8 @@ function CanvasPage({
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{ style={{
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`, "--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
"--canvas-bg-x": `${canvasViewport.x}px`, "--canvas-bg-x": `${canvasViewport.x}px`,
"--canvas-bg-y": `${canvasViewport.y}px`, "--canvas-bg-y": `${canvasViewport.y}px`,
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined, cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
@@ -3730,9 +3748,7 @@ function CanvasPage({
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick} onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
> />
<Background gap={24} color="transparent" className="studio-canvas__background" />
</ReactFlow>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}> <div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button> <button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}> <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
@@ -5534,11 +5550,6 @@ function CanvasPage({
role="menu" role="menu"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}
onMouseMove={(event) => {
if (pendingLinkPort) {
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
}
}}
> >
<div className="studio-canvas-add-node-menu__title"></div> <div className="studio-canvas-add-node-menu__title"></div>
<button <button
+3 -14
View File
@@ -16,10 +16,10 @@ import WorkspacePageShell from "../../components/WorkspacePageShell";
import OptimizedImage from "../../components/OptimizedImage"; import OptimizedImage from "../../components/OptimizedImage";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows"; import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows";
import { ossAssets } from "../../data/ossAssets";
import type { WebCanvasWorkflow, WebProjectSummary } from "../../types"; import type { WebCanvasWorkflow, WebProjectSummary } from "../../types";
import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils"; import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils";
import { ossThumb } from "../../utils/ossImageOptimize"; import { ossThumb } from "../../utils/ossImageOptimize";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
interface CommunityPageProps { interface CommunityPageProps {
projects: WebProjectSummary[]; projects: WebProjectSummary[];
@@ -31,23 +31,12 @@ interface CommunityPageProps {
onRequireLogin?: (action: string) => boolean | void; onRequireLogin?: (action: string) => boolean | void;
} }
const communityCardImages = [ const communityCardImages = ossAssets.community.cardImages;
`${OSS_MUBAN}/dianshang1.png`,
`${OSS_MUBAN}/dianshang2.png`,
`${OSS_MUBAN}/dianshang3.png`,
`${OSS_MUBAN}/wechat-7.png`,
`${OSS_MUBAN}/wechat-8.png`,
`${OSS_MUBAN}/wechat-9.png`,
];
const SLIDE_INTERVAL = 3000; const SLIDE_INTERVAL = 3000;
const CAROUSEL_VISIBLE_COUNT = 3; const CAROUSEL_VISIBLE_COUNT = 3;
const MANUAL_PAUSE_DURATION = 2000; const MANUAL_PAUSE_DURATION = 2000;
const COMMUNITY_CAROUSEL_VIDEOS = [ const COMMUNITY_CAROUSEL_VIDEOS = ossAssets.community.carouselVideos;
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4",
];
function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow { function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow {
const workflow = getWorkflowFromCase(item); const workflow = getWorkflowFromCase(item);
@@ -114,12 +114,12 @@ function DigitalHumanPage({
keepaliveRestoredRef.current = true; keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("digital-human"); const saved = loadToolTaskState("digital-human");
if (!saved || saved.resultUrl) return; if (!saved || saved.resultUrl) return;
setIsProcessing(true); setIsCreating(true);
cancelRef.current = false; cancelRef.current = false;
pollRunRef.current += 1; pollRunRef.current += 1;
setActiveTaskId(saved.taskId); setActiveTaskId(saved.taskId);
void waitForTaskResult(saved.taskId).catch(() => {}); void waitForTaskResult(saved.taskId).catch(() => {});
setStatus("正在恢复数字人任务..."); setNotice("正在恢复数字人任务...");
}, []); }, []);
useEffect(() => { useEffect(() => {
+40 -58
View File
@@ -13,14 +13,11 @@ import {
SkinOutlined, SkinOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react"; import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar"; import { EcommerceProgressBar } from "./EcommerceProgressBar";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const ecommerceGenerated = `${OSS_MUBAN}/ecommerce-carousel-generated.png`;
const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`;
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu"; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace"; import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
import EcommerceDetailPanel from "./panels/EcommerceDetailPanel"; import EcommerceDetailPanel from "./panels/EcommerceDetailPanel";
import EcommerceSetPanel from "./panels/EcommerceSetPanel"; import EcommerceSetPanel from "./panels/EcommerceSetPanel";
import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel";
@@ -71,6 +68,7 @@ interface CloneResult {
id: string; id: string;
src: string; src: string;
label: string; label: string;
type?: "image" | "video";
} }
interface CloneSavedSetting { interface CloneSavedSetting {
@@ -572,7 +570,7 @@ const maxCloneSetTotal = 16;
const maxCloneProductImages = 7; const maxCloneProductImages = 7;
const maxCloneReferenceImages = 20; const maxCloneReferenceImages = 20;
const cloneVideoDurationMin = 5; const cloneVideoDurationMin = 5;
const cloneVideoDurationMax = 15; const cloneVideoDurationMax = 45;
const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting";
const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [ const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [
{ key: "standard", label: "标准", desc: "快速出片" }, { key: "standard", label: "标准", desc: "快速出片" },
@@ -596,15 +594,12 @@ const tryOnModelOptions = {
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"], ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"], body: ["标准", "高挑", "微胖", "运动"],
}; };
const sampleResults = [ecommerceSlide4, ecommerceGenerated, ecommerceSlide5]; const sampleResults = [
const productSetAssets = { ossAssets.ecommerce.slides.slide4,
main: "https://xiuxiu-pro.meitudata.com/poster/6e3eebacad8d5e47e1896ee7d54827bc.png?imageView2/2/w/800/format/webp/q/80/ignore-error/1", ossAssets.ecommerce.generated,
scene: "https://xiuxiu-pro.meitudata.com/poster/21225fc86b28d9e4d85636483c67408e.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1", ossAssets.ecommerce.slides.slide5,
model: "https://xiuxiu-pro.meitudata.com/poster/4b8e6d1bd0996be52822dd1fac73cffd.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1", ];
detail: "https://xiuxiu-pro.meitudata.com/poster/29dd195a450ee5a7f7451ded6680e969.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1", const productSetAssets = ossAssets.ecommerce.productSet;
selling: "https://xiuxiu-pro.meitudata.com/poster/66bdef541b67588e8db2a03b39dc815b.jpg?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
hosting: "https://xiuxiu-pro-new.meitudata.com/poster/50c17a98c77fac4d0523c8cbdf0d33ca.jpg?imageView2/2/format/webp/q/80/ignore-error/1",
};
const productSetPreviewCards = [ const productSetPreviewCards = [
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main }, { id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
{ id: "scene", label: "02 场景展示", src: productSetAssets.scene }, { id: "scene", label: "02 场景展示", src: productSetAssets.scene },
@@ -612,21 +607,7 @@ const productSetPreviewCards = [
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail }, { id: "detail", label: "04 细节说明", src: productSetAssets.detail },
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling }, { id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
]; ];
const tryOnAssets = { const tryOnAssets = ossAssets.ecommerce.tryOn;
dressA: "https://xiuxiu-pro-new.meitudata.com/poster/133ca2d6c13bac6cfaa11fa29a155551.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
dressB: "https://xiuxiu-pro-new.meitudata.com/poster/a661006820e888d9df13023075096e94.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelWoman: "https://xiuxiu-pro-new.meitudata.com/poster/f806c6afaf6f38f634c156c5b6058201.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelMan: "https://xiuxiu-pro-new.meitudata.com/poster/8c26503c67dc695e25e420e48caf4cde.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelAsian: "https://xiuxiu-pro-new.meitudata.com/poster/0f2a7c92707312ec74647d66f15a6ef9.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
tryA: "https://xiuxiu-pro-new.meitudata.com/poster/7f77e0866f05ff723959e1f48830713c.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
tryB: "https://xiuxiu-pro-new.meitudata.com/poster/0b951004eabcdd7cae595dfdb4c7f8c3.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacket: "https://xiuxiu-pro-new.meitudata.com/poster/fdbf10b4c92af5b1986444cdd9affaa5.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
jacketResultA: "https://xiuxiu-pro-new.meitudata.com/poster/b1152bb292323b87696dd2f6e518e818.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacketResultB: "https://xiuxiu-pro-new.meitudata.com/poster/1c1e757702108fef92d85be0c2802c01.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hat: "https://xiuxiu-pro-new.meitudata.com/poster/278af735b076ab812888802d3e3db0b8.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
hatResultA: "https://xiuxiu-pro-new.meitudata.com/poster/a3ba241b7aa6060869b096d3f10e5db4.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hatResultB: "https://xiuxiu-pro-new.meitudata.com/poster/01ed1ae80a187c70c682bb6d0ec6fa68.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
};
const tryOnCards = [ const tryOnCards = [
{ {
@@ -671,18 +652,7 @@ const detailModules = [
const defaultDetailModuleIds: string[] = []; const defaultDetailModuleIds: string[] = [];
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
const cloneDetailModules = detailModules; const cloneDetailModules = detailModules;
const detailAssets = { const detailAssets = ossAssets.ecommerce.detail;
productA: "https://xiuxiu-pro.meitudata.com/poster/182676711565ee98e20cf92d766d1643.png?imageView2/2/format/webp/q/80/ignore-error/1",
productB: "https://xiuxiu-pro.meitudata.com/poster/ba6312cbc3a32ceb8966f9ea20b9ee9c.png?imageView2/2/format/webp/q/80/ignore-error/1",
productC: "https://xiuxiu-pro.meitudata.com/poster/7ee5753a3141fa12cda155126c8225d3.png?imageView2/2/format/webp/q/80/ignore-error/1",
longPage: "https://xiuxiu-pro.meitudata.com/poster/19ef313484fc87c9bdd3cd52ce2a5947.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridA: "https://xiuxiu-pro.meitudata.com/poster/e74e8d920ac0f87020f90457d42a7153.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridB: "https://xiuxiu-pro.meitudata.com/poster/1652064f17c5c2b32ce287244b505c15.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridC: "https://xiuxiu-pro.meitudata.com/poster/dd8abace327edf61d8a8e2d7db42cfbe.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridD: "https://xiuxiu-pro.meitudata.com/poster/7dc397f1cb76a35f7f0ed3c3ce78ba81.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridE: "https://xiuxiu-pro.meitudata.com/poster/1199bd8b968a5162752e1ee2b093d315.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridF: "https://xiuxiu-pro.meitudata.com/poster/7a8cdb3693418df9915741960f8f5aa8.png?imageView2/2/format/webp/q/80/ignore-error/1",
};
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC]; const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF]; const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
@@ -787,6 +757,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [productImages, setProductImages] = useState<CloneImageItem[]>([]); const [productImages, setProductImages] = useState<CloneImageItem[]>([]);
const [isProductUploadDragging, setIsProductUploadDragging] = useState(false); const [isProductUploadDragging, setIsProductUploadDragging] = useState(false);
const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail"); const [cloneOutput, setCloneOutput] = useState<CloneOutputKey>("detail");
const [videoHistoryVisible, setVideoHistoryVisible] = useState(false);
const [videoPlanTrigger, setVideoPlanTrigger] = useState(0);
const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null); const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState<CloneBasicSelectKey | null>(null);
const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null); const [openCloneModelSelect, setOpenCloneModelSelect] = useState<CloneModelSelectKey | null>(null);
const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false); const [cloneModelSelectDropUp, setCloneModelSelectDropUp] = useState(false);
@@ -863,13 +835,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0); const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating"; const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit" const canGenerate = (cloneOutput === "video-outfit"
? videoOutfitVideoFile && videoOutfitRefFile ? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
: productImages.length > 0) && status !== "generating"; : productImages.length > 0) && status !== "generating";
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling"; const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating"; const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress = const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100; ((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle = { const cloneVideoDurationStyle: CSSProperties = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties; } as CSSProperties;
@@ -1413,7 +1385,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string, pRatio: string,
pLanguage: string, pLanguage: string,
pMarket: string, pMarket: string,
setStatusFn: (status: "generating" | "done" | "idle") => void, setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void,
setResultFn: (urls: string[]) => void, setResultFn: (urls: string[]) => void,
): Promise<void> => { ): Promise<void> => {
setStatusFn("generating"); setStatusFn("generating");
@@ -1484,13 +1456,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pMarket: string, pMarket: string,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean }, tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneImageItem[]) => void, resultFn?: (results: CloneResult[]) => void,
): Promise<void> => { ): Promise<void> => {
setStatusFn("generating"); statusFn?.("generating");
try { try {
const referenceUrls = await uploadCloneImages(images); const referenceUrls = await uploadCloneImages(images);
if (!referenceUrls.length) { if (!referenceUrls.length) {
setStatusFn("idle"); statusFn?.("idle");
return; return;
} }
@@ -1514,22 +1486,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
if (resultUrl) { if (resultUrl) {
setResultFn([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]); resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
setStatusFn("done"); statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else { } else {
setStatusFn("idle"); statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
} }
} catch (err) { } catch (err) {
if (err instanceof ServerRequestError && err.status === 402) { if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续"); toast.error("余额不足,请充值后继续");
} else { } else {
const msg = err instanceof Error ? err.message : "生成失败"; const msg = err instanceof Error ? err.message : "生成失败";
toast.error(msg); toast.error(msg);
} }
setStatusFn("failed"); statusFn?.("failed");
} }
}; };
@@ -1563,10 +1535,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
const { waitForTask } = await import("../../api/taskSubscription"); const { waitForTask } = await import("../../api/taskSubscription");
abortRef.current = { current: false }; imageAbortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: abortRef.current }); const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
if (resultUrl) { if (resultUrl) {
setResults([{ id: crypto.randomUUID(), name: "换装视频", src: resultUrl, type: "video", size: 0 }]); setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
} }
setStatus("done"); setStatus("done");
} catch (err) { } catch (err) {
@@ -1602,7 +1574,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage( void generateEcommerceImage(
cloneOutput, productImages, requirement, cloneOutput, productImages, requirement,
platform, ratio, language, market, platform, ratio, language, market,
(s) => setStatus(s as ProductCloneStatus), setResults, undefined,
(s: string) => setStatus(s as ProductCloneStatus), setResults,
); );
lastFailedActionRef.current = () => handleGenerate(); lastFailedActionRef.current = () => handleGenerate();
} }
@@ -1681,7 +1654,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage( void generateEcommerceImage(
"detail", detailProductImages, detailRequirement, "detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
(s) => setDetailStatus(s as DetailStatus), undefined,
(s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null), (res) => setDetailResultUrl(res[0]?.src ?? null),
); );
}; };
@@ -1905,6 +1879,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleGenerate={handleGenerate} handleGenerate={handleGenerate}
formatRatioDisplayValue={formatRatioDisplayValue} formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }} setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
/> />
); );
@@ -2404,6 +2379,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
durationSeconds={cloneVideoDuration} durationSeconds={cloneVideoDuration}
resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"} resolution={cloneVideoQuality === "standard" ? "720P" : "1080P"}
onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))} onRequestLogin={() => ((_props as Record<string, unknown>).isAuthenticated ? undefined : (window.location.hash = "#/login"))}
onOpenHistory={() => setVideoHistoryVisible(true)}
triggerPlan={videoPlanTrigger}
/> />
</main> </main>
) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? ( ) : cloneOutput === "video-outfit" && results.length > 0 && results[0].type === "video" ? (
@@ -2472,6 +2449,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
</section> </section>
</div> </div>
) : null} ) : null}
<EcommerceVideoHistoryPanel
visible={videoHistoryVisible}
onClose={() => setVideoHistoryVisible(false)}
/>
</section> </section>
); );
} }
@@ -1,15 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
CloseOutlined,
CopyOutlined, CopyOutlined,
DownloadOutlined, DownloadOutlined,
FolderAddOutlined, FolderAddOutlined,
HistoryOutlined,
LoadingOutlined, LoadingOutlined,
PlayCircleOutlined,
ReloadOutlined, ReloadOutlined,
SendOutlined, SendOutlined,
StopOutlined, StopOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService"; import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
import { import {
PLAN_STEP_LABELS, PLAN_STEP_LABELS,
PLAN_STEPS_DISPLAY, PLAN_STEPS_DISPLAY,
@@ -39,6 +40,8 @@ interface EcommerceVideoWorkspaceProps {
durationSeconds: number; durationSeconds: number;
resolution: string; resolution: string;
onRequestLogin?: () => void; onRequestLogin?: () => void;
onOpenHistory?: () => void;
triggerPlan?: number;
} }
const ALL_STEPS: PlanStep[] = [ const ALL_STEPS: PlanStep[] = [
@@ -100,6 +103,8 @@ export default function EcommerceVideoWorkspace({
durationSeconds, durationSeconds,
resolution, resolution,
onRequestLogin, onRequestLogin,
onOpenHistory,
triggerPlan,
}: EcommerceVideoWorkspaceProps) { }: EcommerceVideoWorkspaceProps) {
const [stage, setStage] = useState<EcommerceVideoStage>("idle"); const [stage, setStage] = useState<EcommerceVideoStage>("idle");
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null); const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
@@ -111,6 +116,7 @@ export default function EcommerceVideoWorkspace({
const [failedStep, setFailedStep] = useState<PlanStep | null>(null); const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false }); const renderAbortRef = useRef({ current: false });
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
@@ -145,26 +151,45 @@ export default function EcommerceVideoWorkspace({
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls }); saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]); }, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
// ── Auto-advance: skip manual "next step" clicks ───────── // ── Auto-advance: automatically run the full pipeline ─────────
const autoAdvanceTriggeredRef = useRef(false);
useEffect(() => { useEffect(() => {
if (autoAdvanceTriggeredRef.current) return;
const delay = 600; const delay = 600;
if (stage === "planned" && planResult && scenes.length > 0) { if (stage === "planned" && planResult && scenes.length > 0) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleGenerateImages(); }, delay); const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) { if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
autoAdvanceTriggeredRef.current = true;
const timer = setTimeout(() => { void handleRenderVideos(); }, delay); const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
if (stage === "idle" || stage === "cancelled") {
autoAdvanceTriggeredRef.current = false;
}
}, [stage, scenes, planResult]); }, [stage, scenes, planResult]);
// ── External trigger: start plan from parent ────────────────
const triggerPlanPrevRef = useRef(triggerPlan);
useEffect(() => {
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
triggerPlanPrevRef.current = triggerPlan;
void handlePlan();
}
}, [triggerPlan]);
// ── Auto-save: persist completed results to server ──────────
const historySavedRef = useRef(false);
useEffect(() => {
if (stage !== "completed") { historySavedRef.current = false; return; }
if (historySavedRef.current) return;
if (!planResult || !scenes.length) return;
historySavedRef.current = true;
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
saveVideoHistory({
title,
config: { platform, aspectRatio, durationSeconds, resolution },
plan: planResult as unknown as Record<string, unknown>,
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
sourceImageUrls,
}).catch(() => {});
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
// ── Keep-alive: resume polling for running tasks ────────── // ── Keep-alive: resume polling for running tasks ──────────
useEffect(() => { useEffect(() => {
if (keepalivePollingStartedRef.current) return; if (keepalivePollingStartedRef.current) return;
@@ -431,7 +456,7 @@ export default function EcommerceVideoWorkspace({
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try { try {
await renderSceneImage( await renderSceneImage(
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio }, { sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
{ {
onSceneImageSubmitted: (id, taskId) => { onSceneImageSubmitted: (id, taskId) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s)); persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
@@ -486,7 +511,7 @@ export default function EcommerceVideoWorkspace({
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try { try {
await renderScene( await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality }, { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
{ {
onSceneSubmitted: (id, taskId) => { onSceneSubmitted: (id, taskId) => {
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)); persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
@@ -529,7 +554,7 @@ export default function EcommerceVideoWorkspace({
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s)); setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
try { try {
await renderScene( await renderScene(
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) }, { sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) },
{ {
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)), onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)), onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
@@ -573,6 +598,11 @@ export default function EcommerceVideoWorkspace({
</div> </div>
<div className="ecom-video-flowbar__actions"> <div className="ecom-video-flowbar__actions">
{onOpenHistory ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
<HistoryOutlined />
</button>
) : null}
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null} {error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? ( {stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
@@ -580,12 +610,6 @@ export default function EcommerceVideoWorkspace({
<ReloadOutlined /> <ReloadOutlined />
</button> </button>
) : null} ) : null}
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
<button type="button" className="ecom-video-flow-action"
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
<PlayCircleOutlined />
</button>
) : null}
{stage === "planned" || stage === "imaged" ? ( {stage === "planned" || stage === "imaged" ? (
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" <button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}> onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
@@ -638,11 +662,7 @@ export default function EcommerceVideoWorkspace({
{scenes.length > 0 ? scenes.map((s) => ( {scenes.length > 0 ? scenes.map((s) => (
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" /> <div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
)) : ( )) : (
<>
<div className="ecom-video-tree__branch-tap" /> <div className="ecom-video-tree__branch-tap" />
<div className="ecom-video-tree__branch-tap" />
<div className="ecom-video-tree__branch-tap" />
</>
)} )}
</div> </div>
</div> </div>
@@ -672,7 +692,7 @@ export default function EcommerceVideoWorkspace({
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg> <svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div> </div>
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`}> <article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
{imgReady ? ( {imgReady ? (
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} /> <img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
) : ( ) : (
@@ -688,7 +708,7 @@ export default function EcommerceVideoWorkspace({
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg> <svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div> </div>
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`}> <article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
{vidReady ? ( {vidReady ? (
<video src={scene.resultUrl!} muted playsInline loop autoPlay /> <video src={scene.resultUrl!} muted playsInline loop autoPlay />
) : ( ) : (
@@ -709,12 +729,11 @@ export default function EcommerceVideoWorkspace({
</div> </div>
); );
}) : ( }) : (
[1, 2, 3].map((n) => ( <div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
<div key={n} className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`} style={{ animationDelay: `${n * 120}ms` }}>
<article className="ecom-video-tree-node ecom-video-tree-node--text"> <article className="ecom-video-tree-node ecom-video-tree-node--text">
<div className="ecom-video-tree-node__inner"> <div className="ecom-video-tree-node__inner">
<span className="ecom-video-tree-node__title">{n}</span> <span className="ecom-video-tree-node__title"></span>
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span> <span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
</div> </div>
</article> </article>
<div className="ecom-video-tree__arrow" aria-hidden="true"> <div className="ecom-video-tree__arrow" aria-hidden="true">
@@ -724,7 +743,7 @@ export default function EcommerceVideoWorkspace({
<div className="ecom-video-tree-node__placeholder"> <div className="ecom-video-tree-node__placeholder">
{stage === "planning" ? <LoadingOutlined /> : <span></span>} {stage === "planning" ? <LoadingOutlined /> : <span></span>}
</div> </div>
<span className="ecom-video-tree-node__tag">{n}</span> <span className="ecom-video-tree-node__tag"></span>
</article> </article>
<div className="ecom-video-tree__arrow" aria-hidden="true"> <div className="ecom-video-tree__arrow" aria-hidden="true">
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg> <svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
@@ -733,10 +752,9 @@ export default function EcommerceVideoWorkspace({
<div className="ecom-video-tree-node__placeholder"> <div className="ecom-video-tree-node__placeholder">
{stage === "planning" ? <LoadingOutlined /> : <span></span>} {stage === "planning" ? <LoadingOutlined /> : <span></span>}
</div> </div>
<span className="ecom-video-tree-node__tag">{n}</span> <span className="ecom-video-tree-node__tag"></span>
</article> </article>
</div> </div>
))
)} )}
</div> </div>
</div> </div>
@@ -753,6 +771,19 @@ export default function EcommerceVideoWorkspace({
) : null} ) : null}
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null} {actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
</section> </section>
{previewMedia ? (
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
<CloseOutlined />
</button>
{previewMedia.type === "image" ? (
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
) : (
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
)}
</div>
) : null}
</div> </div>
); );
} }
+26 -16
View File
@@ -1,18 +1,28 @@
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png"; import { ossAssets } from "../../data/ossAssets";
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg"; const [
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg"; moreTemplateSlide1,
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png"; moreTemplateSlide2,
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif"; moreTemplateSlide3,
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp"; moreTemplateSlide4,
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp"; moreTemplateSlide5,
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp"; ] = ossAssets.ecommerce.templateSlides;
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp"; const [
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp"; ecommerceHeroSlide1,
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png"; ecommerceHeroSlide2,
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png"; ecommerceHeroSlide3,
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png"; ecommerceHeroSlide4,
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png"; ecommerceHeroSlide5,
] = ossAssets.ecommerce.heroSlides;
const [
ecommerceCarouselImage1,
ecommerceCarouselImage2,
ecommerceCarouselImage3,
ecommerceCarouselImage4,
ecommerceCarouselImage5,
ecommerceCarouselImage6,
] = ossAssets.ecommerce.templateCases;
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
export interface TemplateCase { export interface TemplateCase {
title: string; title: string;
@@ -124,6 +134,6 @@ export const templateCases: TemplateCase[] = [
title: "促销卖点组合图", title: "促销卖点组合图",
category: "详情图", category: "详情图",
summary: "把成分、规格、卖点拆成清晰的详情页模块。", summary: "把成分、规格、卖点拆成清晰的详情页模块。",
imageUrl: "https://picsum.photos/id/1080/900/620", imageUrl: ecommerceCarouselImage6,
}, },
]; ];
+237 -2
View File
@@ -19,6 +19,102 @@ import type {
PlanStep, PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
interface DurableMediaUrl {
url: string | null;
originalUrl?: string | null;
ossKey?: string | null;
}
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
function isTemporaryProviderUrl(url: string): boolean {
try {
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
} catch {
return false;
}
}
function isDurableOssUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
} catch {
return false;
}
}
function getMediaExtension(url: string, mimeType: string): string {
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
if (normalizedMime === "image/jpeg") return "jpg";
if (normalizedMime === "image/png") return "png";
if (normalizedMime === "image/webp") return "webp";
if (normalizedMime === "image/gif") return "gif";
if (normalizedMime === "video/mp4") return "mp4";
if (normalizedMime === "video/webm") return "webm";
if (normalizedMime === "video/quicktime") return "mov";
try {
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
if (matched?.[1]) return matched[1].toLowerCase();
} catch {
// Keep mime fallback below.
}
return mimeType.startsWith("video/") ? "mp4" : "png";
}
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
const normalized = prefix
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
}
export async function resolveDurableMediaUrl(
url: string | null | undefined,
options: {
mediaType: "image" | "video";
namePrefix: string;
scope?: string;
uploadAssetByUrl?: UploadAssetByUrl;
},
): Promise<DurableMediaUrl> {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return { url: null };
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
try {
const uploaded = await uploadAssetByUrl({
sourceUrl,
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
mimeType,
scope: options.scope || "ecommerce-video-history",
});
return {
url: uploaded.url || null,
originalUrl: sourceUrl,
ossKey: uploaded.ossKey || null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
console.warn("[ecommerce-video] history media persistence failed:", message);
if (isTemporaryProviderUrl(sourceUrl)) {
return { url: null, originalUrl: sourceUrl };
}
return { url: sourceUrl };
}
}
export interface PlanCallbacks { export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
@@ -152,6 +248,7 @@ export interface RenderSceneImageInput {
sceneId: number; sceneId: number;
prompt: string; prompt: string;
aspectRatio: string; aspectRatio: string;
productImageUrls: string[];
} }
export interface RenderImageCallbacks { export interface RenderImageCallbacks {
@@ -171,6 +268,7 @@ export async function renderSceneImage(
prompt: input.prompt, prompt: input.prompt,
ratio: input.aspectRatio, ratio: input.aspectRatio,
quality: "2K", quality: "2K",
referenceUrls: input.productImageUrls,
}); });
callbacks.onSceneImageSubmitted(input.sceneId, taskId); callbacks.onSceneImageSubmitted(input.sceneId, taskId);
@@ -192,6 +290,7 @@ export interface RenderSceneInput {
prompt: string; prompt: string;
durationSeconds: number; durationSeconds: number;
imageUrl: string; imageUrl: string;
productImageUrls: string[];
aspectRatio: string; aspectRatio: string;
resolution: string; resolution: string;
model?: string; model?: string;
@@ -209,9 +308,10 @@ export async function renderScene(
callbacks: RenderCallbacks, callbacks: RenderCallbacks,
abortRef: { current: boolean }, abortRef: { current: boolean },
): Promise<void> { ): Promise<void> {
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
const model = resolveVideoRequestModel({ const model = resolveVideoRequestModel({
model: input.model || "happyhorse-1.0", model: input.model || "happyhorse-1.0",
referenceUrls: [input.imageUrl], referenceUrls: allReferenceUrls,
}); });
const { taskId } = await aiGenerationClient.createVideoTask({ const { taskId } = await aiGenerationClient.createVideoTask({
@@ -222,7 +322,7 @@ export async function renderScene(
quality: input.resolution, quality: input.resolution,
resolution: input.resolution, resolution: input.resolution,
frameMode: "start-end", frameMode: "start-end",
referenceUrls: [input.imageUrl], referenceUrls: allReferenceUrls,
hasReferenceVideo: false, hasReferenceVideo: false,
}); });
@@ -254,3 +354,138 @@ export function buildSceneTasks(
}; };
}); });
} }
// ── Video History API ──────────────────────────────────
export interface VideoHistoryScene {
sceneId: number;
prompt: string;
imageUrl?: string | null;
videoUrl?: string | null;
}
interface SaveVideoHistoryPayload {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
uploadAssetByUrl?: UploadAssetByUrl;
}
export interface VideoHistoryItem {
id: number;
title: string;
config: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
createdAt: string;
}
export interface VideoHistoryListResponse {
items: VideoHistoryItem[];
total: number;
limit: number;
offset: number;
}
import { getStoredToken } from "../../api/serverConnection";
const API_BASE = "/api/ai/ecommerce/video-history";
function getAuthHeaders(): Record<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all(
payload.scenes.map(async (scene) => {
const [image, video] = await Promise.all([
resolveDurableMediaUrl(scene.imageUrl, {
mediaType: "image",
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
uploadAssetByUrl,
}),
resolveDurableMediaUrl(scene.videoUrl, {
mediaType: "video",
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
uploadAssetByUrl,
}),
]);
return {
...scene,
imageUrl: image.url,
videoUrl: video.url,
};
}),
);
const sourceImageUrls = (
await Promise.all(
payload.sourceImageUrls.map((url, index) =>
resolveDurableMediaUrl(url, {
mediaType: "image",
namePrefix: `ecommerce-source-${index + 1}`,
uploadAssetByUrl,
}),
),
)
)
.map((item) => item.url)
.filter((url): url is string => Boolean(url));
return {
...payload,
scenes,
sourceImageUrls,
};
}
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload),
});
if (!res.ok) throw new Error("保存历史记录失败");
return res.json();
}
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
return {
...item,
scenes: item.scenes.map((scene) => ({
...scene,
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
})),
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
};
}
export async function fetchVideoHistory(
limit = 20,
offset = 0,
): Promise<VideoHistoryListResponse> {
const res = await fetch(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("获取历史记录失败");
const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
};
}
export async function deleteVideoHistory(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("删除失败");
}
@@ -7,15 +7,18 @@ import {
ReloadOutlined, ReloadOutlined,
SettingOutlined, SettingOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import type { ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react"; import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
type CloneOutputKey = string; type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneSetCountKey = string; type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model"; type CloneModelPanelTab = "scene" | "model";
type CloneReferenceMode = "upload" | "link"; type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = string; type CloneReplicateLevelKey = "style" | "high";
type CloneVideoQualityKey = string; type CloneVideoQualityKey = "standard" | "high" | "ultra";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
interface CloneImageItem { interface CloneImageItem {
id: string; id: string;
@@ -24,7 +27,7 @@ interface CloneImageItem {
} }
interface CloneBasicSelectItem { interface CloneBasicSelectItem {
key: string; key: CloneBasicSelectKey;
label: string; label: string;
value: string; value: string;
options: string[]; options: string[];
@@ -32,7 +35,7 @@ interface CloneBasicSelectItem {
} }
interface CloneModelSelectItem { interface CloneModelSelectItem {
key: string; key: CloneModelSelectKey;
label: string; label: string;
value: string; value: string;
options: string[]; options: string[];
@@ -76,7 +79,7 @@ interface EcommerceClonePanelProps {
cloneOutput: CloneOutputKey; cloneOutput: CloneOutputKey;
cloneOutputOptions: CloneOutputOption[]; cloneOutputOptions: CloneOutputOption[];
cloneBasicSelects: CloneBasicSelectItem[]; cloneBasicSelects: CloneBasicSelectItem[];
openCloneBasicSelect: string | null; openCloneBasicSelect: CloneBasicSelectKey | null;
cloneReferenceMode: CloneReferenceMode; cloneReferenceMode: CloneReferenceMode;
cloneReferenceImages: CloneImageItem[]; cloneReferenceImages: CloneImageItem[];
maxCloneReferenceImages: number; maxCloneReferenceImages: number;
@@ -94,7 +97,7 @@ interface EcommerceClonePanelProps {
selectedCloneModelScenes: string[]; selectedCloneModelScenes: string[];
cloneModelCustomScene: string; cloneModelCustomScene: string;
cloneModelSelects: CloneModelSelectItem[]; cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: string | null; openCloneModelSelect: CloneModelSelectKey | null;
cloneModelSelectDropUp: boolean; cloneModelSelectDropUp: boolean;
cloneModelAppearance: string; cloneModelAppearance: string;
cloneVideoQuality: CloneVideoQualityKey; cloneVideoQuality: CloneVideoQualityKey;
@@ -102,27 +105,27 @@ interface EcommerceClonePanelProps {
cloneVideoDuration: number; cloneVideoDuration: number;
cloneVideoDurationMin: number; cloneVideoDurationMin: number;
cloneVideoDurationMax: number; cloneVideoDurationMax: number;
cloneVideoDurationStyle: { [key: string]: number | string }; cloneVideoDurationStyle: CSSProperties;
cloneVideoSmart: boolean; cloneVideoSmart: boolean;
canGenerate: boolean; canGenerate: boolean;
status: string; status: string;
lastFailedActionRef: MutableRefObject<(() => void) | null>; lastFailedActionRef: MutableRefObject<(() => void) | null>;
setIsProductUploadDragging: (value: boolean) => void; setIsProductUploadDragging: (value: boolean) => void;
handleProductDrop: (event: DragEvent<HTMLElement>) => void; handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
removeProductImage: (id: string) => void; removeProductImage: (id: string) => void;
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void; handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleCloneOutputChange: (value: CloneOutputKey) => void; handleCloneOutputChange: (value: CloneOutputKey) => void;
setOpenCloneBasicSelect: (value: string | null) => void; setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void; setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void; handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void; setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void; startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
clearCloneSetCountHold: () => void; clearCloneSetCountHold: () => void;
toggleCloneDetailModule: (id: string) => void; toggleCloneDetailModule: (id: string) => void;
setCloneModelPanelTab: (value: CloneModelPanelTab) => void; setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
toggleCloneModelScene: (scene: string) => void; toggleCloneModelScene: (scene: string) => void;
setCloneModelCustomScene: (value: string) => void; setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: string | null) => void; setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void; setCloneModelSelectDropUp: (value: boolean) => void;
setCloneModelAppearance: (value: string) => void; setCloneModelAppearance: (value: string) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void; setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
@@ -132,6 +135,7 @@ interface EcommerceClonePanelProps {
handleGenerate: () => void; handleGenerate: () => void;
formatRatioDisplayValue: (value: string) => string; formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void; setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
} }
export default function EcommerceClonePanel({ export default function EcommerceClonePanel({
@@ -198,6 +202,7 @@ export default function EcommerceClonePanel({
handleGenerate, handleGenerate,
formatRatioDisplayValue, formatRatioDisplayValue,
setVideoOutfitFiles, setVideoOutfitFiles,
onStartVideoPlan,
}: EcommerceClonePanelProps) { }: EcommerceClonePanelProps) {
const videoOutfitVideoRef = useRef<HTMLInputElement>(null); const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
const videoOutfitRefRef = useRef<HTMLInputElement>(null); const videoOutfitRefRef = useRef<HTMLInputElement>(null);
@@ -666,15 +671,16 @@ export default function EcommerceClonePanel({
type="range" type="range"
min={cloneVideoDurationMin} min={cloneVideoDurationMin}
max={cloneVideoDurationMax} max={cloneVideoDurationMax}
step={1} step={5}
value={cloneVideoDuration} value={cloneVideoDuration}
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))} onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
aria-label="短视频时长" aria-label="短视频时长"
/> />
<div className="clone-ai-duration-scale" aria-hidden="true"> <div className="clone-ai-duration-scale" aria-hidden="true">
<span>5</span> <span>5</span>
<span>10</span>
<span>15</span> <span>15</span>
<span>30</span>
<span>45</span>
</div> </div>
</div> </div>
</div> </div>
@@ -693,6 +699,12 @@ export default function EcommerceClonePanel({
</section> </section>
) : null} ) : null}
{cloneOutput === "video" && onStartVideoPlan ? (
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
</button>
) : null}
{cloneOutput === "video-outfit" ? ( {cloneOutput === "video-outfit" ? (
<section className="clone-ai-video-panel" aria-label="视频换装"> <section className="clone-ai-video-panel" aria-label="视频换装">
<div className="clone-ai-video-section"> <div className="clone-ai-video-section">
@@ -1,12 +1,14 @@
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons"; import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
import type { ChangeEvent, DragEvent, RefObject } from "react"; import type { ChangeEvent, DragEvent, RefObject } from "react";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
interface EcommerceSetPanelProps { interface EcommerceSetPanelProps {
setInputRef: RefObject<HTMLInputElement>; setInputRef: RefObject<HTMLInputElement>;
setImages: Array<{ id: string; src: string; name: string }>; setImages: Array<{ id: string; src: string; name: string }>;
isSetUploadDragging: boolean; isSetUploadDragging: boolean;
productSetOutputOptions: Array<{ key: string; label: string }>; productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }>;
productSetOutput: string; productSetOutput: ProductSetOutputKey;
platformOptions: string[]; platformOptions: string[];
marketOptions: string[]; marketOptions: string[];
productSetLanguageOptions: string[]; productSetLanguageOptions: string[];
@@ -16,10 +18,10 @@ interface EcommerceSetPanelProps {
productSetLanguage: string; productSetLanguage: string;
productSetRatio: string; productSetRatio: string;
setIsSetUploadDragging: (value: boolean) => void; setIsSetUploadDragging: (value: boolean) => void;
handleSetDrop: (event: DragEvent<HTMLElement>) => void; handleSetDrop: (event: DragEvent<HTMLButtonElement>) => void;
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void; handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
removeSetImage: (id: string) => void; removeSetImage: (id: string) => void;
handleProductSetOutputChange: (value: string) => void; handleProductSetOutputChange: (value: ProductSetOutputKey) => void;
handleProductSetPlatformChange: (value: string) => void; handleProductSetPlatformChange: (value: string) => void;
handleProductSetMarketChange: (value: string) => void; handleProductSetMarketChange: (value: string) => void;
setProductSetLanguage: (value: string) => void; setProductSetLanguage: (value: string) => void;
@@ -0,0 +1,185 @@
import { useCallback, useEffect, useState } from "react";
import {
CloseOutlined,
DeleteOutlined,
ExclamationCircleOutlined,
HistoryOutlined,
LoadingOutlined,
PlayCircleOutlined,
} from "@ant-design/icons";
import {
fetchVideoHistory,
deleteVideoHistory,
type VideoHistoryItem,
} from "../ecommerceVideoService";
interface EcommerceVideoHistoryPanelProps {
visible: boolean;
onClose: () => void;
}
export default function EcommerceVideoHistoryPanel({
visible,
onClose,
}: EcommerceVideoHistoryPanelProps) {
const [items, setItems] = useState<VideoHistoryItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [previewMedia, setPreviewMedia] = useState<{
url: string;
type: "image" | "video";
} | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const limit = 10;
const load = useCallback(async (off: number) => {
setLoading(true);
try {
const res = await fetchVideoHistory(limit, off);
setItems(res.items);
setTotal(res.total);
setOffset(off);
} catch { /* silent */ }
setLoading(false);
}, []);
useEffect(() => {
if (visible) load(0);
}, [visible, load]);
const handleDelete = async (id: number) => {
try {
await deleteVideoHistory(id);
setItems((prev) => prev.filter((i) => i.id !== id));
setTotal((t) => t - 1);
} catch { /* silent */ }
setConfirmDeleteId(null);
};
if (!visible) return null;
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
return (
<>
<div className="ecom-video-history-panel">
<div className="ecom-video-history-panel__header">
<HistoryOutlined />
<span></span>
<button className="ecom-video-history-panel__close" onClick={onClose}>
<CloseOutlined />
</button>
</div>
<div className="ecom-video-history-panel__body">
{loading && !items.length ? (
<div className="ecom-video-history-panel__empty">
<LoadingOutlined style={{ fontSize: 24 }} />
<span>...</span>
</div>
) : !items.length ? (
<div className="ecom-video-history-panel__empty">
<HistoryOutlined style={{ fontSize: 32, opacity: 0.3 }} />
<span></span>
</div>
) : (
items.map((item) => (
<div key={item.id} className="ecom-video-history-card">
<div className="ecom-video-history-card__header">
<span className="ecom-video-history-card__title">
{item.title || "未命名"}
</span>
<span className="ecom-video-history-card__date">
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
</span>
<button
className="ecom-video-history-card__delete"
onClick={() => setConfirmDeleteId(item.id)}
title="删除"
>
<DeleteOutlined />
</button>
</div>
<div className="ecom-video-history-card__scenes">
{item.scenes.map((scene, idx) => (
<div key={idx} className="ecom-video-history-card__scene">
{scene.imageUrl && (
<img
src={scene.imageUrl}
alt={`分镜${idx + 1}`}
onClick={() =>
setPreviewMedia({ url: scene.imageUrl!, type: "image" })
}
/>
)}
{scene.videoUrl && (
<div
className="ecom-video-history-card__video-thumb"
onClick={() =>
setPreviewMedia({ url: scene.videoUrl!, type: "video" })
}
>
<PlayCircleOutlined />
</div>
)}
</div>
))}
</div>
</div>
))
)}
</div>
{totalPages > 1 && (
<div className="ecom-video-history-panel__pager">
<button disabled={currentPage <= 1} onClick={() => load(offset - limit)}>
</button>
<span>{currentPage}/{totalPages}</span>
<button disabled={currentPage >= totalPages} onClick={() => load(offset + limit)}>
</button>
</div>
)}
</div>
{confirmDeleteId !== null && (
<div className="ecom-video-confirm-dialog-backdrop" onClick={() => setConfirmDeleteId(null)}>
<div className="ecom-video-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<ExclamationCircleOutlined className="ecom-video-confirm-dialog__icon" />
<p className="ecom-video-confirm-dialog__text">
</p>
<div className="ecom-video-confirm-dialog__actions">
<button onClick={() => setConfirmDeleteId(null)}></button>
<button className="is-danger" onClick={() => handleDelete(confirmDeleteId)}>
</button>
</div>
</div>
</div>
)}
{previewMedia && (
<div
className="ecom-video-preview-overlay"
onClick={() => setPreviewMedia(null)}
>
<button
className="ecom-video-preview-overlay__close"
onClick={() => setPreviewMedia(null)}
>
<CloseOutlined />
</button>
{previewMedia.type === "image" ? (
<img src={previewMedia.url} alt="preview" />
) : (
<video src={previewMedia.url} controls autoPlay />
)}
</div>
)}
</>
);
}
+8 -8
View File
@@ -10,6 +10,7 @@ import {
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types"; import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance"; import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import { ossAssets } from "../../data/ossAssets";
import WelcomeSplash from "./WelcomeSplash"; import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection"; import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase"; import ScriptReviewShowcase from "./ScriptReviewShowcase";
@@ -24,13 +25,12 @@ function ScrollEntrance({ children, className, ...rest }: { children: React.Reac
); );
} }
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban"; const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const heroImage1 = `${OSS_MUBAN}/hero-1.png`; const {
const heroImage2 = `${OSS_MUBAN}/hero-2.png`; ecommerce: featureEcommerceImage,
const heroImage3 = `${OSS_MUBAN}/hero-3.png`; script: featureScriptImage,
const featureEcommerceImage = `${OSS_MUBAN}/feature-ecommerce.jpg`; token: featureTokenImage,
const featureScriptImage = `${OSS_MUBAN}/feature-script.jpg`; } = ossAssets.home.features;
const featureTokenImage = `${OSS_MUBAN}/feature-token.jpg`;
interface HomePageProps { interface HomePageProps {
onOpenGenerate: () => void; onOpenGenerate: () => void;
@@ -42,7 +42,7 @@ interface HomePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void; onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
} }
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/hero-bg.mp4"; const HOME_BACKGROUND_VIDEO = ossAssets.home.backgroundVideo;
const HOME_CAROUSEL_IMAGES = [ const HOME_CAROUSEL_IMAGES = [
{ imageUrl: heroImage1, title: "灵感生成" }, { imageUrl: heroImage1, title: "灵感生成" },
+8 -4
View File
@@ -1,9 +1,13 @@
import { ToolOutlined } from "@ant-design/icons"; import { ToolOutlined } from "@ant-design/icons";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types"; import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import toolImageBefore from "../../assets/toolbox/牛仔.png"; import { ossAssets } from "../../data/ossAssets";
import toolImageAfter from "../../assets/toolbox/西装.png";
import watermarkBefore from "../../assets/toolbox/去水印前.png"; const {
import watermarkAfter from "../../assets/toolbox/去水印后.png"; imageBefore: toolImageBefore,
imageAfter: toolImageAfter,
watermarkBefore,
watermarkAfter,
} = ossAssets.toolbox;
interface ToolboxSectionProps { interface ToolboxSectionProps {
onSelectView: (view: WebViewKey) => void; onSelectView: (view: WebViewKey) => void;
@@ -148,22 +148,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
keepaliveRestoredRef.current = true; keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("imagewb"); const saved = loadToolTaskState("imagewb");
if (!saved || saved.resultUrl) return; if (!saved || saved.resultUrl) return;
setIsGenerating(true); setGenerating(true);
abortRef.current = false; abortRef.current = false;
taskIdRef.current = saved.taskId; taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, { void waitForTask(saved.taskId, {
onProgress: (e) => { onProgress: (e) => {
setTaskProgress(Math.max(0, Math.min(100, Math.trunc(e.progress || 0))));
setStatus(`${e.status} / ${e.progress}%`); setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) { if (e.status === "completed" && e.resultUrl) {
setResultImages([e.resultUrl]); setResultImages([e.resultUrl]);
clearToolTaskState("imagewb"); clearToolTaskState("imagewb");
setIsGenerating(false); setGenerating(false);
setStatus("恢复任务完成"); setStatus("恢复任务完成");
} }
if (e.status === "failed") { if (e.status === "failed") {
clearToolTaskState("imagewb"); clearToolTaskState("imagewb");
setIsGenerating(false); setGenerating(false);
setStatus("恢复任务失败"); setStatus("恢复任务失败");
} }
}, },
+103 -55
View File
@@ -5,10 +5,13 @@ import {
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined, LockOutlined,
MailOutlined, MailOutlined,
MobileOutlined, MobileOutlined,
PhoneOutlined, PhoneOutlined,
PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SafetyOutlined, SafetyOutlined,
ShareAltOutlined, ShareAltOutlined,
@@ -20,6 +23,7 @@ import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient"; import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import { keyServerClient } from "../../api/keyServerClient"; import { keyServerClient } from "../../api/keyServerClient";
import { isServerRequestError } from "../../api/serverConnection"; import { isServerRequestError } from "../../api/serverConnection";
import { ossAssets } from "../../data/ossAssets";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types"; import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore"; import type { SavedAssetItem } from "../assets/localAssetStore";
@@ -44,8 +48,8 @@ type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks"; type AccountPanel = "credits" | "tasks";
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui"; const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; const AUTH_LOGO_URL = ossAssets.brand.logo;
const AUTH_SHOWCASE_VIDEO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test5.mp4"; const AUTH_SHOWCASE_VIDEO_URL = ossAssets.auth.showcaseVideo;
function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string { function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`; return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`;
@@ -179,6 +183,19 @@ function formatAssetStatus(status: string | undefined): string {
return status || "资产"; return status || "资产";
} }
function formatAssetType(type: SavedAssetItem["type"]): string {
const labels: Record<string, string> = {
character: "角色",
scene: "场景",
prop: "道具",
video: "视频",
image: "图像",
asset: "资产",
other: "素材",
};
return labels[type] || "素材";
}
function ProfilePage({ function ProfilePage({
session, session,
usage, usage,
@@ -607,22 +624,50 @@ function ProfilePage({
</div> </div>
); );
const renderCardPreview = (
url: string | null | undefined,
type: "image" | "video" | "project" | "asset",
label: string,
) => {
const mediaUrl = typeof url === "string" ? url.trim() : "";
const isVideoPreview = type === "video" || /\.(mp4|webm|mov)(\?|#|$)/i.test(mediaUrl);
const placeholderIcon =
type === "video" ? <PlayCircleOutlined /> : type === "project" ? <FolderOpenOutlined /> : <FileImageOutlined />;
return (
<div className={`profile-page__list-card-preview${mediaUrl ? " has-media" : ""}`} aria-hidden="true">
{mediaUrl ? (
isVideoPreview ? (
<video src={mediaUrl} muted playsInline preload="metadata" />
) : (
<img src={mediaUrl} alt="" loading="lazy" />
)
) : (
<span className="profile-page__list-card-placeholder">{placeholderIcon}</span>
)}
<span className="profile-page__media-badge">{label}</span>
</div>
);
};
const renderActivePanel = () => { const renderActivePanel = () => {
if (activePanel === "works") { if (activePanel === "works") {
return visibleWorks.length ? ( return visibleWorks.length ? (
<div className="profile-page__works-scroll"> <div className="profile-page__works-scroll">
<div className="profile-page__list-grid motion-stagger"> <div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => ( {visibleWorks.map((task) => (
<article key={task.id} className="profile-page__list-card"> <article key={task.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head"> <div className="profile-page__list-card-head">
<strong>{task.title}</strong> <strong>{task.title}</strong>
<span>{formatTaskType(task.type)}</span>
</div> </div>
<p>{task.prompt}</p> <p>{task.prompt}</p>
<div className="profile-page__list-card-meta"> <div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span> <span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span> <span>{formatProfileDate(task.createdAt)}</span>
</div> </div>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -636,10 +681,11 @@ function ProfilePage({
return projects.length ? ( return projects.length ? (
<div className="profile-page__list-grid motion-stagger"> <div className="profile-page__list-grid motion-stagger">
{projects.map((project) => ( {projects.map((project) => (
<article key={project.id} className="profile-page__list-card"> <article key={project.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head"> <div className="profile-page__list-card-head">
<strong>{project.name}</strong> <strong>{project.name}</strong>
<span>{formatProfileDate(project.updatedAt)}</span>
{onDeleteProject ? ( {onDeleteProject ? (
<button <button
type="button" type="button"
@@ -654,7 +700,8 @@ function ProfilePage({
<p>{project.description || "最近更新的项目"}</p> <p>{project.description || "最近更新的项目"}</p>
<div className="profile-page__list-card-meta"> <div className="profile-page__list-card-meta">
<span>{project.storyboardCount} </span> <span>{project.storyboardCount} </span>
<span>{project.imageCount} / {project.videoCount} </span> <span>{formatProfileDate(project.updatedAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -668,16 +715,19 @@ function ProfilePage({
return savedAssets.length ? ( return savedAssets.length ? (
<div className="profile-page__list-grid"> <div className="profile-page__list-grid">
{savedAssets.map((asset) => ( {savedAssets.map((asset) => (
<article key={asset.id} className="profile-page__list-card"> <article key={asset.id} className="profile-page__list-card profile-page__media-card">
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<div className="profile-page__list-card-body">
<div className="profile-page__list-card-head"> <div className="profile-page__list-card-head">
<strong>{asset.name}</strong> <strong>{asset.name}</strong>
<span>{formatAssetStatus(asset.status)}</span> <span>{formatAssetStatus(asset.status)}</span>
</div> </div>
<p>{asset.description}</p> <p>{asset.description}</p>
<div className="profile-page__list-card-meta"> <div className="profile-page__list-card-meta">
<span>{asset.type}</span> <span>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span> <span>{formatProfileDate(asset.updatedAt)}</span>
</div> </div>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -790,6 +840,50 @@ function ProfilePage({
</div> </div>
</div> </div>
<div className="profile-page__account-card">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
</>
)}
</div>
</div>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan"> <button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined /> <ShareAltOutlined />
{packageLabel} {packageLabel}
@@ -837,52 +931,6 @@ function ProfilePage({
</span> </span>
{renderActivePanel()} {renderActivePanel()}
</div> </div>
<div className="profile-page__section">
<div className="profile-page__list-bar">
<div className="profile-page__list-tabs">
<button
type="button"
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
</button>
</div>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
</>
)}
</div>
</div>
</main> </main>
</div> </div>
</section> </section>
+22 -4
View File
@@ -1,5 +1,6 @@
import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "@ant-design/icons"; import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "@ant-design/icons";
import { useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
import { reportClient, type ReportInput } from "../../api/reportClient"; import { reportClient, type ReportInput } from "../../api/reportClient";
type SubmitState = "idle" | "loading" | "success" | "error"; type SubmitState = "idle" | "loading" | "success" | "error";
@@ -31,6 +32,7 @@ function ReportPage() {
const [contactPhone, setContactPhone] = useState(""); const [contactPhone, setContactPhone] = useState("");
const [submitState, setSubmitState] = useState<SubmitState>("idle"); const [submitState, setSubmitState] = useState<SubmitState>("idle");
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
const canSubmit = const canSubmit =
submitState !== "loading" && reportType !== "" && title.trim() !== "" && description.trim() !== ""; submitState !== "loading" && reportType !== "" && title.trim() !== "" && description.trim() !== "";
@@ -48,6 +50,22 @@ function ReportPage() {
setErrorMsg(""); setErrorMsg("");
}; };
useEffect(() => {
let cancelled = false;
publicConfigClient
.get()
.then((config) => {
if (!cancelled) setPublicConfig(config);
})
.catch(() => {
if (!cancelled) setPublicConfig({});
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return; if (!canSubmit) return;
@@ -85,9 +103,9 @@ function ReportPage() {
</header> </header>
<div className="report-contact-strip"> <div className="report-contact-strip">
<span><MailOutlined /> {import.meta.env.VITE_REPORT_EMAIL || "support@omniai.com"}</span> <span><MailOutlined /> {publicConfig.contactEmail || "由服务器配置"}</span>
<span><PhoneOutlined /> {import.meta.env.VITE_REPORT_PHONE || "请在环境变量配置客服电话"}</span> <span><PhoneOutlined /> {publicConfig.contactPhone || "由服务器配置"}</span>
<span>{import.meta.env.VITE_ICP_RECORD || "ICP备案信息待配置"}</span> <span>{publicConfig.icpRecord || "由服务器配置"}</span>
</div> </div>
{submitState === "success" ? ( {submitState === "success" ? (
+18 -66
View File
@@ -10,6 +10,7 @@ import {
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import { evaluateScript } from "../../api/scriptEvalClient"; import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
import { useSessionStore } from "../../stores"; import { useSessionStore } from "../../stores";
interface ScoreDimension { interface ScoreDimension {
@@ -175,61 +176,6 @@ 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 paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
if (paraMatches) {
return paraMatches.map((p) => {
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
if (!tMatches) return "";
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, "\"")).join("");
}).filter(Boolean).join("\n").trim();
}
return "";
}
function formatFileSize(size: number): string { function formatFileSize(size: number): string {
if (size < 1024) return `${size} B`; if (size < 1024) return `${size} B`;
@@ -321,22 +267,26 @@ function ScriptTokensPage() {
const ext = getFileExtension(file.name); const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext); const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size }); setUploadedFile({ name: file.name, size: file.size });
if (ext === ".docx") { if (ext === ".docx" || ext === ".doc") {
try { try {
const bytes = new Uint8Array(await file.arrayBuffer()); const formData = new FormData();
const text = await extractDocxText(bytes); formData.append("file", file);
if (text) { const token = getStoredToken();
setScript(text); const resp = await fetch(buildApiUrl("files/extract-text"), {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (resp.ok) {
const { text } = await resp.json();
setScript(text || "");
} else { } else {
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`); const err = await resp.json().catch(() => ({ error: "解析失败" }));
setScript(`[已上传文件:${file.name}]\n\n${err.error || "文件解析失败,请尝试另存为 TXT 格式后重新上传。"}`);
} }
} catch { } catch {
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传`); setScript(`[已上传文件:${file.name}]\n\n文件解析请求失败,请检查网络连接后重试`);
} }
} else if (ext === ".doc") {
const text = await decodeTextFile(file);
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
} else if (readable) { } else if (readable) {
const text = normalizeUploadedText(await decodeTextFile(file), ext); const text = normalizeUploadedText(await decodeTextFile(file), ext);
setScript(text); setScript(text);
@@ -455,6 +405,7 @@ function ScriptTokensPage() {
<div className="script-eval-v5-page"> <div className="script-eval-v5-page">
{/* Left Panel */} {/* Left Panel */}
<aside className="script-eval-v5-left"> <aside className="script-eval-v5-left">
<div className="script-eval-v5-left-main">
<div className="script-eval-v5-lp-section"> <div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label"></div> <div className="script-eval-v5-lp-label"></div>
<div <div
@@ -561,6 +512,7 @@ function ScriptTokensPage() {
<span></span> <span></span>
</button> </button>
</div> </div>
</div>
</aside> </aside>
{/* Right Area */} {/* Right Area */}
@@ -271,9 +271,8 @@ function TokenUsagePage({
) : null} ) : null}
<section className="management-metric-cards" aria-label="关键指标"> <section className="management-metric-cards" aria-label="关键指标">
{metricCards.map((card, index) => ( {metricCards.map((card) => (
<article key={card.key} className={`management-metric-card is-${card.tone}`}> <article key={card.key} className={`management-metric-card is-${card.tone}`}>
<span className="management-metric-card__index">{String(index + 1).padStart(2, "0")}</span>
<span className="management-metric-card__label">{card.label}</span> <span className="management-metric-card__label">{card.label}</span>
<strong className="management-metric-card__value">{card.value}</strong> <strong className="management-metric-card__value">{card.value}</strong>
<span className="management-metric-card__hint">{card.hint}</span> <span className="management-metric-card__hint">{card.hint}</span>
-5
View File
@@ -999,11 +999,6 @@ function WorkbenchPage({
}); });
removeKeepaliveTask(task.taskId); removeKeepaliveTask(task.taskId);
onRefreshUsage?.(); onRefreshUsage?.();
if (status.status === "completed") {
import("../../utils/generationNotifier").then((m) =>
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
);
}
return; return;
} }
+3 -3
View File
@@ -19,18 +19,18 @@ export interface UseGenerationStatusReturn {
export function useGenerationStatus(): UseGenerationStatusReturn { export function useGenerationStatus(): UseGenerationStatusReturn {
const [status, setStatus] = useState<GenStatus>("idle"); const [status, setStatus] = useState<GenStatus>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortRef = useRef({ current: false }); const abortRef = useRef(false);
const start = useCallback(() => { const start = useCallback(() => {
setStatus("generating"); setStatus("generating");
setError(null); setError(null);
abortRef.current = { current: false }; abortRef.current = false;
}, []); }, []);
const succeed = useCallback(() => setStatus("done"), []); const succeed = useCallback(() => setStatus("done"), []);
const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []); const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []);
const reset = useCallback(() => { setStatus("idle"); setError(null); }, []); const reset = useCallback(() => { setStatus("idle"); setError(null); }, []);
const cancel = useCallback(() => { abortRef.current.current = true; }, []); const cancel = useCallback(() => { abortRef.current = true; }, []);
return { return {
status, error, abortRef, start, succeed, fail, reset, cancel, status, error, abortRef, start, succeed, fail, reset, cancel,
+349 -29
View File
@@ -179,9 +179,9 @@
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
background: #101318; background: #101318;
padding: 26px; padding: 32px 40px;
display: flex; display: flex;
align-items: center; align-items: stretch;
justify-content: center; justify-content: center;
} }
@@ -735,10 +735,10 @@
.ecom-video-tree { .ecom-video-tree {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: 0; gap: 0;
width: 100%; width: 100%;
min-height: 0; min-height: 100%;
} }
/* ── Source node ── */ /* ── Source node ── */
@@ -746,9 +746,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
gap: 10px; gap: 10px;
flex-shrink: 0; flex-shrink: 0;
align-self: center;
} }
.ecom-video-tree-node { .ecom-video-tree-node {
@@ -762,8 +762,8 @@
} }
.ecom-video-tree-node--source { .ecom-video-tree-node--source {
width: 150px; width: 180px;
height: 190px; height: 230px;
flex-shrink: 0; flex-shrink: 0;
border-color: #1c4d3a; border-color: #1c4d3a;
background: #162820; background: #162820;
@@ -785,9 +785,9 @@
/* ── Text node (分镜文本) ── */ /* ── Text node (分镜文本) ── */
.ecom-video-tree-node--text { .ecom-video-tree-node--text {
min-width: 120px; min-width: 140px;
max-width: 150px; max-width: 170px;
padding: 14px 12px; padding: 16px 14px;
cursor: default; cursor: default;
border-color: #2a3d30; border-color: #2a3d30;
background: #131d1a; background: #131d1a;
@@ -824,8 +824,8 @@
/* ── Image node (分镜图) ── */ /* ── Image node (分镜图) ── */
.ecom-video-tree-node--image, .ecom-video-tree-node--image,
.ecom-video-tree-node--video { .ecom-video-tree-node--video {
width: 150px; width: 170px;
height: 120px; height: 136px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -919,7 +919,7 @@
/* ── Trunk connector (分支连接线) ── */ /* ── Trunk connector (分支连接线) ── */
.ecom-video-tree__trunk { .ecom-video-tree__trunk {
position: relative; position: relative;
width: 48px; width: 56px;
flex-shrink: 0; flex-shrink: 0;
align-self: stretch; align-self: stretch;
} }
@@ -928,7 +928,7 @@
position: absolute; position: absolute;
left: 0; left: 0;
top: 50%; top: 50%;
width: 24px; width: 28px;
height: 2px; height: 2px;
background: #3a4550; background: #3a4550;
transform: translateY(-50%); transform: translateY(-50%);
@@ -948,10 +948,15 @@
.ecom-video-tree__branches-line { .ecom-video-tree__branches-line {
position: absolute; position: absolute;
left: 24px; left: 28px;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 24px; width: 28px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
padding: 0;
} }
.ecom-video-tree__branches-line::before { .ecom-video-tree__branches-line::before {
@@ -965,24 +970,28 @@
} }
.ecom-video-tree__branch-tap { .ecom-video-tree__branch-tap {
position: absolute; position: relative;
left: 0; flex: 1;
display: flex;
align-items: center;
}
.ecom-video-tree__branch-tap::before {
content: "";
display: block;
width: 100%; width: 100%;
height: 2px; height: 2px;
background: #3a4550; background: #3a4550;
} }
.ecom-video-tree__branch-tap:nth-child(1) { top: 0; }
.ecom-video-tree__branch-tap:nth-child(2) { top: 50%; transform: translateY(-50%); }
.ecom-video-tree__branch-tap:nth-child(3) { bottom: 0; }
.ecom-video-tree__branch-tap::after { .ecom-video-tree__branch-tap::after {
content: ""; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 50%;
transform: translateY(-50%);
width: 100%; width: 100%;
height: 100%; height: 2px;
background: linear-gradient(90deg, transparent, #00ff88, transparent); background: linear-gradient(90deg, transparent, #00ff88, transparent);
animation: ecom-tree-branch-flow 2.4s ease-in-out infinite; animation: ecom-tree-branch-flow 2.4s ease-in-out infinite;
} }
@@ -1016,18 +1025,19 @@
.ecom-video-tree__rows { .ecom-video-tree__rows {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; gap: 0;
gap: 20px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
align-self: center; align-self: stretch;
padding: 8px 0; padding: 0;
} }
.ecom-video-tree__row { .ecom-video-tree__row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: flex-start;
flex: 1;
gap: 12px;
animation: ecom-tree-row-in 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both; animation: ecom-tree-row-in 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
} }
@@ -1091,3 +1101,313 @@
70% { opacity: 0.5; } 70% { opacity: 0.5; }
100% { opacity: 0; transform: translateX(100%); } 100% { opacity: 0; transform: translateX(100%); }
} }
/* ── Preview lightbox overlay ────────────────────── */
.ecom-video-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
cursor: zoom-out;
animation: ecom-preview-fade-in 200ms ease;
}
.ecom-video-preview-overlay__close {
position: absolute;
top: 24px;
right: 24px;
z-index: 10;
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 18px;
cursor: pointer;
}
.ecom-video-preview-overlay img,
.ecom-video-preview-overlay video {
max-width: 90vw;
max-height: 85vh;
border-radius: 8px;
object-fit: contain;
cursor: default;
}
@keyframes ecom-preview-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── History panel ──────────────────────────────── */
.ecom-video-history-panel {
position: fixed;
top: 0;
right: 0;
z-index: 9000;
display: flex;
flex-direction: column;
width: 420px;
max-width: 90vw;
height: 100vh;
background: #1a1d24;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
animation: ecom-history-slide-in 0.25s ease-out;
}
@keyframes ecom-history-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.ecom-video-history-panel__header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 15px;
font-weight: 500;
}
.ecom-video-history-panel__close {
margin-left: auto;
display: grid;
width: 28px;
height: 28px;
place-items: center;
border: none;
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
cursor: pointer;
}
.ecom-video-history-panel__close:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.ecom-video-history-panel__body {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ecom-video-history-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
}
.ecom-video-history-card {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
padding: 14px;
}
.ecom-video-history-card__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.ecom-video-history-card__title {
color: #fff;
font-size: 13px;
font-weight: 500;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ecom-video-history-card__date {
color: rgba(255, 255, 255, 0.4);
font-size: 11px;
white-space: nowrap;
}
.ecom-video-history-card__delete {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
cursor: pointer;
}
.ecom-video-history-card__delete:hover {
background: rgba(255, 80, 80, 0.15);
color: #ff5050;
}
.ecom-video-history-card__scenes {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.ecom-video-history-card__scene {
position: relative;
flex-shrink: 0;
width: 80px;
height: 60px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.ecom-video-history-card__scene img {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: opacity 0.15s;
}
.ecom-video-history-card__scene img:hover {
opacity: 0.8;
}
.ecom-video-history-card__video-thumb {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 20px;
cursor: pointer;
transition: background 0.15s;
}
.ecom-video-history-card__video-thumb:hover {
background: rgba(0, 0, 0, 0.2);
}
.ecom-video-history-panel__pager {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
}
.ecom-video-history-panel__pager button {
padding: 4px 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
cursor: pointer;
}
.ecom-video-history-panel__pager button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.ecom-video-history-panel__pager button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Delete confirmation dialog ─────────────────── */
.ecom-video-confirm-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.ecom-video-confirm-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 28px 32px;
border-radius: 12px;
background: #1e2128;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
max-width: 340px;
text-align: center;
}
.ecom-video-confirm-dialog__icon {
font-size: 36px;
color: #faad14;
}
.ecom-video-confirm-dialog__text {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
}
.ecom-video-confirm-dialog__actions {
display: flex;
gap: 12px;
margin-top: 4px;
}
.ecom-video-confirm-dialog__actions button {
padding: 6px 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.ecom-video-confirm-dialog__actions button:hover {
background: rgba(255, 255, 255, 0.08);
}
.ecom-video-confirm-dialog__actions button.is-danger {
background: #ff4d4f;
border-color: #ff4d4f;
color: #fff;
}
.ecom-video-confirm-dialog__actions button.is-danger:hover {
background: #ff7875;
border-color: #ff7875;
}
+185 -73
View File
@@ -2831,10 +2831,10 @@
.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(260px, 380px) 54px minmax(400px, 1fr);
align-items: center; align-items: center;
gap: 20px; gap: 28px;
width: min(100%, 960px); width: min(100%, 1120px);
} }
.product-clone-page[data-tool="clone"] .clone-ai-main-result, .product-clone-page[data-tool="clone"] .clone-ai-main-result,
@@ -2842,24 +2842,26 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid #2c3038; border: 1px solid #2c3038;
border-radius: 14px; border-radius: 16px;
background: #1b1d23; background: #1b1d23;
padding: 0; padding: 0;
cursor: pointer; cursor: pointer;
transition: transition:
border-color 160ms ease, border-color 200ms ease,
transform 160ms ease; transform 200ms ease,
box-shadow 200ms ease;
} }
.product-clone-page[data-tool="clone"] .clone-ai-main-result:hover, .product-clone-page[data-tool="clone"] .clone-ai-main-result:hover,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover { .product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover {
border-color: #00ff88; border-color: #00ff88;
transform: translateY(-1px); transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 255, 136, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3);
} }
.product-clone-page[data-tool="clone"] .clone-ai-main-result:active, .product-clone-page[data-tool="clone"] .clone-ai-main-result:active,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:active { .product-clone-page[data-tool="clone"] .clone-ai-result-grid button:active {
transform: scale(0.98); transform: scale(0.97);
} }
.product-clone-page[data-tool="clone"] .clone-ai-main-result img, .product-clone-page[data-tool="clone"] .clone-ai-main-result img,
@@ -2868,39 +2870,46 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 300ms ease;
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result:hover img,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover img {
transform: scale(1.03);
} }
.product-clone-page[data-tool="clone"] .clone-ai-main-result { .product-clone-page[data-tool="clone"] .clone-ai-main-result {
height: 360px; height: 440px;
} }
.product-clone-page[data-tool="clone"] .clone-ai-result-grid { .product-clone-page[data-tool="clone"] .clone-ai-result-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 14px;
} }
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button { .product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
height: 172px; height: 210px;
} }
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:first-child { .product-clone-page[data-tool="clone"] .clone-ai-result-grid button:first-child {
grid-column: 1 / -1; grid-column: 1 / -1;
height: 190px; height: 240px;
} }
.product-clone-page[data-tool="clone"] .clone-ai-main-result span, .product-clone-page[data-tool="clone"] .clone-ai-main-result span,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid span { .product-clone-page[data-tool="clone"] .clone-ai-result-grid span {
position: absolute; position: absolute;
left: 11px; left: 12px;
top: 11px; top: 12px;
max-width: calc(100% - 22px); max-width: calc(100% - 24px);
overflow: hidden; overflow: hidden;
border: 1px solid #303540; border: 1px solid rgba(48, 53, 64, 0.6);
border-radius: 999px; border-radius: 999px;
background: #15171c; background: rgba(21, 23, 28, 0.85);
backdrop-filter: blur(8px);
color: #d8deed; color: #d8deed;
padding: 6px 10px; padding: 7px 13px;
font-size: 12px; font-size: 12px;
font-weight: 900; font-weight: 900;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -3793,8 +3802,8 @@
.product-clone-thumb-row, .product-clone-thumb-row,
.product-clone-ref-grid { .product-clone-ref-grid {
display: grid; display: grid;
gap: 8px; gap: 10px;
margin-top: 10px; margin-top: 12px;
} }
.product-clone-thumb-row { .product-clone-thumb-row {
@@ -3805,7 +3814,13 @@
.product-clone-ref-grid img { .product-clone-ref-grid img {
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
border-radius: 8px; border-radius: 10px;
transition: transform 250ms ease;
}
.product-clone-thumb-row img:hover,
.product-clone-ref-grid img:hover {
transform: scale(1.03);
} }
.product-clone-thumb-row img { .product-clone-thumb-row img {
@@ -3989,12 +4004,12 @@
display: grid; display: grid;
align-content: center; align-content: center;
justify-items: center; justify-items: center;
gap: 34px; gap: 36px;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
background: #f5f6f8; background: #f5f6f8;
padding: 42px; padding: 48px;
} }
.product-clone-preview__headline { .product-clone-preview__headline {
@@ -4018,21 +4033,29 @@
.product-clone-demo-board { .product-clone-demo-board {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(260px, 340px) 44px minmax(300px, 360px); grid-template-columns: minmax(300px, 400px) 48px minmax(340px, 420px);
align-items: center; align-items: center;
gap: 30px; gap: 34px;
width: min(100%, 780px); width: min(100%, 920px);
border-radius: 22px; border-radius: 24px;
background: #ffffff; background: #ffffff;
padding: 30px; padding: 34px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
} }
.product-clone-source-card, .product-clone-source-card,
.product-clone-result-stack figure { .product-clone-result-stack figure {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 14px; border-radius: 16px;
background: #f2f4f7; background: #f2f4f7;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-clone-source-card:hover,
.product-clone-result-stack figure:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06);
} }
.product-clone-source-card img, .product-clone-source-card img,
@@ -4041,16 +4064,23 @@
width: 100%; width: 100%;
aspect-ratio: 1.55; aspect-ratio: 1.55;
object-fit: cover; object-fit: cover;
transition: transform 300ms ease;
}
.product-clone-source-card:hover img,
.product-clone-result-stack figure:hover img {
transform: scale(1.02);
} }
.product-clone-source-card span, .product-clone-source-card span,
.product-clone-result-stack figcaption { .product-clone-result-stack figcaption {
position: absolute; position: absolute;
top: 10px; top: 12px;
right: 10px; right: 12px;
border-radius: 999px; border-radius: 999px;
background: #ffffff; background: rgba(255, 255, 255, 0.9);
padding: 6px 10px; backdrop-filter: blur(6px);
padding: 7px 13px;
color: #111827; color: #111827;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -4099,7 +4129,7 @@
.product-clone-result-stack { .product-clone-result-stack {
display: grid; display: grid;
gap: 10px; gap: 12px;
} }
.product-clone-result-stack figure { .product-clone-result-stack figure {
@@ -4849,19 +4879,25 @@
.product-set-demo-board { .product-set-demo-board {
display: grid; display: grid;
grid-template-columns: 336px 40px 338px; grid-template-columns: minmax(300px, 420px) 44px minmax(340px, 1fr);
align-items: center; align-items: center;
gap: 24px; gap: 28px;
width: min(100%, 802px); width: min(100%, 960px);
min-height: 336px; min-height: 380px;
} }
.product-set-demo-board figure { .product-set-demo-board figure {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
border-radius: 12px; border-radius: 14px;
background: #ffffff; background: #ffffff;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-set-demo-board figure:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
} }
.product-set-demo-board img { .product-set-demo-board img {
@@ -4869,6 +4905,11 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 300ms ease;
}
.product-set-demo-board figure:hover img {
transform: scale(1.03);
} }
.product-set-demo-board figcaption { .product-set-demo-board figcaption {
@@ -4878,35 +4919,43 @@
max-width: calc(100% - 24px); max-width: calc(100% - 24px);
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.86); background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(6px);
color: #111827; color: #111827;
padding: 6px 10px; padding: 7px 14px;
font-size: 12px; font-size: 13px;
font-weight: 900; font-weight: 900;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.product-set-main-card { .product-set-main-card {
height: 336px; height: 380px;
border-radius: 16px;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-set-main-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
} }
.product-set-flow-arrow { .product-set-flow-arrow {
width: 40px; width: 44px;
height: 24px; height: 26px;
border-radius: 999px; border-radius: 999px;
background: #b8c3d1; background: linear-gradient(90deg, #b8c3d1, #d7dde6);
clip-path: polygon(0 28%, 58% 28%, 58% 0, 100% 50%, 58% 100%, 58% 72%, 0 72%); clip-path: polygon(0 28%, 58% 28%, 58% 0, 100% 50%, 58% 100%, 58% 72%, 0 72%);
} }
.product-set-card-grid { .product-set-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 14px;
} }
.product-set-card-grid figure { .product-set-card-grid figure {
height: 162px; height: 184px;
} }
.product-set-generated-note { .product-set-generated-note {
@@ -5360,13 +5409,13 @@
} }
.product-clone-page[data-tool="set"] .product-set-demo-board { .product-clone-page[data-tool="set"] .product-set-demo-board {
grid-template-columns: minmax(360px, 486px) 44px minmax(360px, 486px); grid-template-columns: minmax(380px, 1fr) 48px minmax(380px, 1fr);
gap: 28px; gap: 32px;
width: min(100%, 1150px); width: min(100%, 1200px);
min-height: 576px; min-height: 620px;
border-radius: 32px; border-radius: 32px;
background: #ffffff; background: #ffffff;
padding: 37px 30px; padding: 40px 34px;
} }
.product-clone-page[data-tool="set"] .product-set-demo-board figure { .product-clone-page[data-tool="set"] .product-set-demo-board figure {
@@ -5376,16 +5425,16 @@
} }
.product-clone-page[data-tool="set"] .product-set-main-card { .product-clone-page[data-tool="set"] .product-set-main-card {
height: 502px; height: 540px;
background: #ffffff; background: #ffffff;
} }
.product-clone-page[data-tool="set"] .product-set-card-grid { .product-clone-page[data-tool="set"] .product-set-card-grid {
gap: 18px; gap: 20px;
} }
.product-clone-page[data-tool="set"] .product-set-card-grid figure { .product-clone-page[data-tool="set"] .product-set-card-grid figure {
height: 242px; height: 260px;
} }
.product-clone-page[data-tool="set"] .product-set-demo-board figcaption { .product-clone-page[data-tool="set"] .product-set-demo-board figcaption {
@@ -6840,26 +6889,37 @@
.product-try-on-generated { .product-try-on-generated {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 14px;
width: min(100%, 766px); width: min(100%, 820px);
border-radius: 18px; border-radius: 20px;
background: #ffffff; background: #ffffff;
padding: 14px; padding: 16px;
} }
.product-try-on-generated figure { .product-try-on-generated figure {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
border-radius: 12px; border-radius: 14px;
background: #edf1f6; background: #edf1f6;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-try-on-generated figure:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
} }
.product-try-on-generated img { .product-try-on-generated img {
display: block; display: block;
width: 100%; width: 100%;
height: 180px; height: 200px;
object-fit: cover; object-fit: cover;
transition: transform 300ms ease;
}
.product-try-on-generated figure:hover img {
transform: scale(1.03);
} }
.product-try-on-generated figcaption { .product-try-on-generated figcaption {
@@ -6867,8 +6927,9 @@
left: 10px; left: 10px;
bottom: 10px; bottom: 10px;
border-radius: 999px; border-radius: 999px;
background: #ffffff; background: rgba(255, 255, 255, 0.88);
padding: 5px 10px; backdrop-filter: blur(6px);
padding: 6px 12px;
color: #111827; color: #111827;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -7524,15 +7585,27 @@
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
border: 1px solid #dfe5ee; border: 1px solid #dfe5ee;
border-radius: 10px; border-radius: 12px;
background: #f5f6f8; background: #f5f6f8;
aspect-ratio: 1; aspect-ratio: 1;
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
}
.product-set-thumb:hover {
border-color: #c6cdd8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
} }
.product-set-thumb img { .product-set-thumb img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: transform 250ms ease;
}
.product-set-thumb:hover img {
transform: scale(1.04);
} }
.product-set-thumb button { .product-set-thumb button {
@@ -7540,12 +7613,13 @@
top: 6px; top: 6px;
right: 6px; right: 6px;
display: grid; display: grid;
width: 24px; width: 26px;
height: 24px; height: 26px;
place-items: center; place-items: center;
border: 1px solid #dfe5ee; border: 1px solid rgba(223, 229, 238, 0.7);
border-radius: 999px; border-radius: 999px;
background: #ffffff; background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
color: #111827; color: #111827;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -8524,9 +8598,8 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-logo { .product-clone-page[data-tool="clone"] .clone-ai-logo {
position: sticky; position: static;
top: 0; z-index: auto;
z-index: 3;
margin: -18px -18px 2px; margin: -18px -18px 2px;
padding: 16px 18px 14px; padding: 16px 18px 14px;
border-bottom-color: var(--ecm-line); border-bottom-color: var(--ecm-line);
@@ -9029,7 +9102,7 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-logo { .product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: -14px -14px 0; margin: 0;
padding: 14px 54px 12px 14px; padding: 14px 54px 12px 14px;
} }
@@ -9308,3 +9381,42 @@
padding-top: 14px; padding-top: 14px;
} }
} }
/* Mobile clone header alignment: keep the tool title in normal flow, but attach it to the top nav rhythm. */
@media (max-width: 900px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding-top: 0;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -18px 2px;
}
}
@media (max-width: 620px) {
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding: 0 14px 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -14px 0;
}
}
@media (max-width: 480px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
}
+70 -29
View File
@@ -596,14 +596,27 @@ textarea.image-workbench-prompt {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 12px;
color: var(--fg-dim); width: 100%;
height: 100%;
color: var(--fg-muted);
font-size: 14px; font-size: 14px;
} }
.image-workbench-empty .anticon { .image-workbench-empty .anticon {
font-size: 32px; font-size: 40px;
opacity: 0.5; opacity: 0.35;
}
.image-workbench-empty strong {
font-size: 18px;
color: var(--fg-body, #eee);
}
.image-workbench-empty span {
max-width: 320px;
text-align: center;
line-height: 1.5;
} }
.image-workbench-empty--button { .image-workbench-empty--button {
@@ -824,22 +837,24 @@ textarea.image-workbench-prompt {
.image-workbench-panel--right .image-workbench-result-grid { .image-workbench-panel--right .image-workbench-result-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 8px; gap: 10px;
margin-top: 8px; margin-top: 10px;
} }
.image-workbench-result-thumb { .image-workbench-result-thumb {
display: block; display: block;
overflow: hidden; overflow: hidden;
border-radius: 6px; border-radius: 8px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
aspect-ratio: 1; aspect-ratio: 1;
transition: border-color 0.15s; transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
} }
.image-workbench-result-thumb:hover { .image-workbench-result-thumb:hover {
border-color: var(--accent, #2dd4bf); border-color: var(--accent, #2dd4bf);
transform: scale(1.04);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
.image-workbench-result-thumb img { .image-workbench-result-thumb img {
@@ -1598,30 +1613,30 @@ textarea.image-workbench-prompt {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 14px;
width: 100%; width: 100%;
height: 100%; height: 100%;
color: var(--fg-muted); color: var(--fg-muted);
} }
.image-workbench-generating strong { .image-workbench-generating strong {
font-size: 20px; font-size: 22px;
color: var(--fg-default); color: var(--fg-default);
} }
.image-workbench-progress-bar { .image-workbench-progress-bar {
width: 320px; width: min(420px, 80%);
height: 8px; height: 10px;
border-radius: 4px; border-radius: 5px;
background: var(--bg-inset); background: var(--bg-inset);
overflow: hidden; overflow: hidden;
} }
.image-workbench-progress-fill { .image-workbench-progress-fill {
height: 100%; height: 100%;
border-radius: 4px; border-radius: 5px;
background: var(--accent); background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
transition: width 0.3s ease; transition: width 0.35s ease;
} }
.image-workbench-cancel { .image-workbench-cancel {
@@ -1642,30 +1657,30 @@ textarea.image-workbench-prompt {
} }
.image-workbench-result-grid { .image-workbench-result-grid {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
align-items: center;
justify-content: center;
align-content: center; align-content: center;
justify-items: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 24px; padding: 32px;
overflow-y: auto; overflow-y: auto;
gap: 16px; gap: 20px;
} }
.image-workbench-result-item { .image-workbench-result-item {
display: block; display: block;
border-radius: var(--radius-sm); border-radius: var(--radius-md, 12px);
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-weak); border: 1px solid var(--border-weak);
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
} }
.image-workbench-result-item:hover { .image-workbench-result-item:hover {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(var(--accent-rgb, 45, 212, 191), 0.1);
transform: translateY(-2px);
} }
.image-workbench-result-item img { .image-workbench-result-item img {
@@ -1674,20 +1689,26 @@ textarea.image-workbench-prompt {
height: auto; height: auto;
object-fit: contain; object-fit: contain;
background: var(--bg-inset); background: var(--bg-inset);
transition: transform 300ms ease;
}
.image-workbench-result-item:hover img {
transform: scale(1.02);
} }
.image-workbench-result-card { .image-workbench-result-card {
display: grid; display: grid;
min-width: 0; min-width: 0;
width: min(100%, 500px); width: 100%;
max-width: 560px;
align-content: start; align-content: start;
gap: 12px; gap: 14px;
} }
.image-workbench-result-actions { .image-workbench-result-actions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 10px;
} }
.image-workbench-result-actions button { .image-workbench-result-actions button {
@@ -1735,3 +1756,23 @@ textarea.image-workbench-prompt {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* Result card entrance animation */
@keyframes image-workbench-result-enter {
from {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.image-workbench-result-card {
animation: image-workbench-result-enter 0.4s ease-out both;
}
.image-workbench-result-card:nth-child(2) { animation-delay: 0.08s; }
.image-workbench-result-card:nth-child(3) { animation-delay: 0.16s; }
.image-workbench-result-card:nth-child(4) { animation-delay: 0.24s; }
File diff suppressed because it is too large Load Diff
+8
View File
@@ -559,12 +559,20 @@
} }
.web-shell__page { .web-shell__page {
position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
scrollbar-color: rgba(var(--accent-rgb), 0.42) transparent; scrollbar-color: rgba(var(--accent-rgb), 0.42) transparent;
} }
.keepalive-ecommerce {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
}
/* ── Info button & popover ────────────────────── */ /* ── Info button & popover ────────────────────── */
.info-button { .info-button {
display: inline-grid; display: inline-grid;
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,5 +1,4 @@
const ERROR_REPORT_ENDPOINT = "/api/client-errors"; const ERROR_REPORT_ENDPOINT = "/api/client-errors";
const CLIENT_ERROR_REPORTING_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_REPORTING === "1";
interface ErrorReport { interface ErrorReport {
message: string; message: string;
@@ -28,12 +27,16 @@ function getSessionId(): string | undefined {
function flush() { function flush() {
if (reportQueue.length === 0) return; if (reportQueue.length === 0) return;
const batch = reportQueue.splice(0, 10); const batch = reportQueue.splice(0, 10);
const baseUrl = import.meta.env.VITE_API_BASE_URL || ""; const payload = new Blob([JSON.stringify({ errors: batch })], { type: "application/json" });
const url = `${baseUrl}${ERROR_REPORT_ENDPOINT}`; if (navigator.sendBeacon?.(ERROR_REPORT_ENDPOINT, payload)) return;
const token = localStorage.getItem("omniai:token") || sessionStorage.getItem("omniai:token") || "";
const headers: Record<string, string> = { "Content-Type": "application/json" }; void fetch(ERROR_REPORT_ENDPOINT, {
if (token) headers["Authorization"] = `Bearer ${token}`; method: "POST",
navigator.sendBeacon?.(url, new Blob([JSON.stringify({ errors: batch })], { type: "application/json" })); body: JSON.stringify({ errors: batch }),
headers: { "Content-Type": "application/json" },
credentials: "include",
keepalive: true,
}).catch(() => {});
} }
function scheduleFlush() { function scheduleFlush() {
@@ -45,8 +48,6 @@ function scheduleFlush() {
} }
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") { export function reportError(error: unknown, source: ErrorReport["source"] = "manual") {
if (!CLIENT_ERROR_REPORTING_ENABLED) return;
const err = error instanceof Error ? error : new Error(String(error)); const err = error instanceof Error ? error : new Error(String(error));
const report: ErrorReport = { const report: ErrorReport = {
message: err.message, message: err.message,
+6 -15
View File
@@ -1,28 +1,20 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2"; import { compression } from "vite-plugin-compression2";
import { defineConfig, loadEnv } from "vite"; import { defineConfig } from "vite";
export default defineConfig(({ mode }) => { export default defineConfig(() => ({
const env = loadEnv(mode, process.cwd(), "");
return {
plugins: [ plugins: [
react(), react(),
compression({ algorithms: ["gzip", "brotliCompress"], threshold: 1024 }), compression({ algorithms: ["gzip", "brotliCompress"], threshold: 1024 }),
], ],
server: { server: {
port: 5174, port: 5173,
host: "127.0.0.1", host: "127.0.0.1",
proxy: { proxy: {
"/api": { "/api": {
target: env.VITE_DEV_PROXY || "http://47.110.225.76:3600", target: "https://omniai.net.cn",
changeOrigin: true, changeOrigin: true,
}, },
"/dashscope-api": {
target: "https://dashscope.aliyuncs.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dashscope-api/, "/compatible-mode/v1"),
},
}, },
}, },
preview: { preview: {
@@ -33,7 +25,7 @@ export default defineConfig(({ mode }) => {
drop: ["console", "debugger"], drop: ["console", "debugger"],
}, },
build: { build: {
sourcemap: "hidden", sourcemap: false,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id: string) { manualChunks(id: string) {
@@ -50,5 +42,4 @@ export default defineConfig(({ mode }) => {
}, },
}, },
}, },
}; }));
});