chore: migrate frontend assets to OSS and same-origin APIs

This commit is contained in:
2026-06-04 16:03:49 +08:00
parent 7c6129555b
commit c7c52c1467
55 changed files with 728 additions and 292 deletions
+5 -8
View File
@@ -1,8 +1,5 @@
# Dev proxy target — the backend API server
VITE_DEV_PROXY=http://47.110.225.76:3600
# Key server URL for auth/profile endpoints
VITE_KEY_SERVER_URL=
# Main API base URL (used when not served from omniai.net.cn)
VITE_API_BASE_URL=
# Frontend environment variables are intentionally unsupported.
#
# API traffic must go through same-origin /api.
# Public runtime settings must come from application APIs.
# Provider keys and OSS credentials must stay on the server.
+3 -1
View File
@@ -10,6 +10,8 @@ node_modules/
Thumbs.db
.vscode/
.idea/
.claude/
tmp/
*.swp
*.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",
"preview": "vite preview --host 127.0.0.1",
"type-check": "tsc -p tsconfig.json --noEmit",
"governance:check": "node scripts/check-governance.mjs",
"style:check": "node scripts/check-style-governance.mjs",
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
},
+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";
+5 -2
View File
@@ -28,6 +28,7 @@ import {
SERVER_SESSION_REPLACED_EVENT,
SERVER_SESSION_EXPIRED_EVENT,
checkServerHealth,
clearAllUserStorage,
getErrorMessage,
type ServerSessionReplacedDetail,
} from "./api/serverConnection";
@@ -143,7 +144,9 @@ function normalizeViewKey(rawView: string): WebViewKey {
}
function readViewFromHash(): WebViewKey {
return normalizeViewKey(window.location.hash.replace(/^#\/?/, ""));
const raw = window.location.hash.replace(/^#\/?/, "");
if (!raw) return "home";
return normalizeViewKey(raw);
}
function isWorkspaceView(view: WebViewKey): boolean {
@@ -375,7 +378,7 @@ function App() {
}, [setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
keyServerClient.clearSession();
clearAllUserStorage();
clearSessionState();
setProjects([]);
setProjectsLoaded(true);
-1
View File
@@ -67,7 +67,6 @@ let modelCapabilitiesRouteMissing = false;
export const modelCapabilitiesClient = {
async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> {
if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities();
if (modelCapabilitiesRouteMissing) return createFallbackCapabilities();
let payload: unknown;
+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;
}
},
};
+48 -25
View File
@@ -1,6 +1,5 @@
import type { WebUserSession } from "../types";
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
@@ -59,34 +58,12 @@ export function compactMessage(value: string): string {
}
export function getServerBaseUrl(): string {
const envBaseUrl = String(
import.meta.env.VITE_KEY_SERVER_URL ||
import.meta.env.VITE_SERVER_BASE_URL ||
import.meta.env.VITE_API_BASE_URL ||
"",
).trim();
const shouldUseSameOriginApi =
typeof window !== "undefined" &&
(window.location.protocol === "https:" ||
window.location.hostname === "omniai.net.cn" ||
window.location.hostname === "www.omniai.net.cn");
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
return "";
}
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
return "";
}
export function buildApiUrl(path: string): string {
const cleanPath = path.replace(/^\/+/, "");
const baseUrl = getServerBaseUrl();
if (!baseUrl) return `/api/${cleanPath}`;
try {
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
} catch {
return `${baseUrl}/api/${cleanPath}`;
}
return `/api/${cleanPath}`;
}
export function canUseSessionStorage(): boolean {
@@ -167,6 +144,39 @@ export function writeStoredSession(session: WebUserSession | null): void {
}
}
export function clearAllUserStorage(): void {
writeStoredSession(null);
try {
if (typeof window === "undefined") return;
const legacyKeys = ["omniai:token", "omniai:session"];
for (const key of legacyKeys) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
}
const prefixKeys = [
"omniai-web-profile-ui",
"omniai:more-recent-tools",
"omniai:generation-queue",
"omniai-canvas-saved-assets",
];
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.localStorage.removeItem(key);
}
}
for (let i = window.sessionStorage.length - 1; i >= 0; i--) {
const key = window.sessionStorage.key(i);
if (key && prefixKeys.some((p) => key.startsWith(p))) {
window.sessionStorage.removeItem(key);
}
}
} catch {
// best-effort cleanup
}
}
export function getStoredToken(): string | null {
return readStoredSession()?.token ?? null;
}
@@ -226,6 +236,15 @@ let lastSessionReplacedEventAt = 0;
let lastSessionExpiredEventAt = 0;
function isNonAuthErrorCode(code: string | undefined): boolean {
if (!code) return false;
return [
"ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED",
"INSUFFICIENT_BALANCE",
"INSUFFICIENT_ENTERPRISE_BALANCE",
].includes(code);
}
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
if (status !== 401 && status !== 403) return;
if (typeof window === "undefined") return;
@@ -238,6 +257,9 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
if (!readStoredSession()) return;
// Deliberate early-exit for unauthenticated users — not a real auth failure.
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
// Non-auth 403 errors (enterprise model access, insufficient balance) must
// not trigger session expiry.
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
const now = Date.now();
if (now - lastSessionExpiredEventAt < 1500) return;
@@ -341,6 +363,7 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
headers,
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller ? controller.signal : options?.signal,
credentials: "include",
});
const payload = await readJsonResponse<unknown>(response, "Request failed");
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,
LoginOutlined,
LogoutOutlined,
PhoneOutlined,
SafetyOutlined,
EnvironmentOutlined,
PlusCircleOutlined,
UserOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
import NotificationCenter from "./NotificationCenter";
@@ -40,8 +39,7 @@ interface AppShellProps {
children: ReactNode;
}
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1";
const BRAND_LOGO_URL = ossAssets.brand.logo;
function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100;
@@ -71,6 +69,7 @@ function AppShell({
const [infoOpen, setInfoOpen] = useState(false);
const infoRef = useRef<HTMLDivElement>(null);
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
const prevActiveViewRef = useRef<WebViewKey>(activeView);
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login";
@@ -136,6 +135,22 @@ function AppShell({
}
}, []);
useEffect(() => {
let cancelled = false;
publicConfigClient
.get()
.then((config) => {
if (!cancelled) setPublicConfig(config);
})
.catch(() => {
if (!cancelled) setPublicConfig({});
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!profileOpen) return;
@@ -220,7 +235,6 @@ function AppShell({
? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents)
: usage.balanceCents;
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
const isPreviewSession = session?.source === "mock-fallback";
const showCommunityReview = canReviewCommunity(session);
const showCommunityCaseAdd = canManageCommunityCases(session);
@@ -339,11 +353,11 @@ function AppShell({
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
<dl>
<dt></dt>
<dd>ICP备2026021747号-1</dd>
<dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
<dt></dt>
<dd>9A楼501</dd>
<dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
<dt></dt>
<dd>15155073618</dd>
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
</dl>
<div className="info-popover__links">
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}></a>
@@ -407,7 +421,7 @@ function AppShell({
<dd>{usage.videoUsed}</dd>
</dl>
<div className="profile-popover__footer">
<span>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span>
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
<button type="button" onClick={onLogout}>
<LogoutOutlined />
退
@@ -473,7 +487,7 @@ function AppShell({
<div className="web-shell__page">{children}</div>
</main>
</div>
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
{session?.user.role === "admin" ? <AdminMonitor /> : null}
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
<CookieConsentBanner />
</div>
+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 { ossAssets } from "./ossAssets";
const [rainNightImage, characterLookImage, skylineImage, labImage] = ossAssets.workflows.caseImages;
function createNodes(
title: string,
@@ -69,7 +72,7 @@ export const communityCases: WebCommunityCase[] = [
author: "Dave",
tag: "视频案例",
summary: "从街口推到人物面部,强调雨夜反光与情绪收束。",
imageUrl: "https://picsum.photos/id/1011/900/540",
imageUrl: rainNightImage,
workflow: {
id: "workflow-rain-night",
version: 1,
@@ -83,7 +86,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "6s",
resolution: "720p",
},
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", "https://picsum.photos/id/1011/960/540"),
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", rainNightImage),
edges: createEdges(),
},
},
@@ -93,7 +96,7 @@ export const communityCases: WebCommunityCase[] = [
author: "SuperXe",
tag: "角色案例",
summary: "把单张角色图扩展成可连续出片的角色工作流。",
imageUrl: "https://picsum.photos/id/1027/900/540",
imageUrl: characterLookImage,
workflow: {
id: "workflow-character-look",
version: 1,
@@ -107,7 +110,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "5s",
resolution: "720p",
},
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", "https://picsum.photos/id/1027/960/540"),
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", characterLookImage),
edges: createEdges(),
},
},
@@ -117,7 +120,7 @@ export const communityCases: WebCommunityCase[] = [
author: "OmniAI",
tag: "风景案例",
summary: "用广角风景做镜头进入,适合转场和开场片头。",
imageUrl: "https://picsum.photos/id/1050/900/540",
imageUrl: skylineImage,
workflow: {
id: "workflow-skyline",
version: 1,
@@ -131,7 +134,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "8s",
resolution: "1080p",
},
nodes: createNodes("风景开场,镜头缓慢推进到天际线", "https://picsum.photos/id/1050/960/540"),
nodes: createNodes("风景开场,镜头缓慢推进到天际线", skylineImage),
edges: createEdges(),
},
},
@@ -141,7 +144,7 @@ export const communityCases: WebCommunityCase[] = [
author: "Studio",
tag: "实验案例",
summary: "更适合拆解推拉摇移和节奏控制的实验模板。",
imageUrl: "https://picsum.photos/id/1056/900/540",
imageUrl: labImage,
workflow: {
id: "workflow-lab",
version: 1,
@@ -155,7 +158,7 @@ export const communityCases: WebCommunityCase[] = [
duration: "6s",
resolution: "720p",
},
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", "https://picsum.photos/id/1056/960/540"),
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", labImage),
edges: createEdges(),
},
},
+3 -14
View File
@@ -16,10 +16,10 @@ import WorkspacePageShell from "../../components/WorkspacePageShell";
import OptimizedImage from "../../components/OptimizedImage";
import { EmptyState } from "../../components/EmptyState";
import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows";
import { ossAssets } from "../../data/ossAssets";
import type { WebCanvasWorkflow, WebProjectSummary } from "../../types";
import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils";
import { ossThumb } from "../../utils/ossImageOptimize";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
interface CommunityPageProps {
projects: WebProjectSummary[];
@@ -31,23 +31,12 @@ interface CommunityPageProps {
onRequireLogin?: (action: string) => boolean | void;
}
const communityCardImages = [
`${OSS_MUBAN}/dianshang1.png`,
`${OSS_MUBAN}/dianshang2.png`,
`${OSS_MUBAN}/dianshang3.png`,
`${OSS_MUBAN}/wechat-7.png`,
`${OSS_MUBAN}/wechat-8.png`,
`${OSS_MUBAN}/wechat-9.png`,
];
const communityCardImages = ossAssets.community.cardImages;
const SLIDE_INTERVAL = 3000;
const CAROUSEL_VISIBLE_COUNT = 3;
const MANUAL_PAUSE_DURATION = 2000;
const COMMUNITY_CAROUSEL_VIDEOS = [
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4",
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4",
];
const COMMUNITY_CAROUSEL_VIDEOS = ossAssets.community.carouselVideos;
function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow {
const workflow = getWorkflowFromCase(item);
+13 -44
View File
@@ -13,12 +13,8 @@ import {
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const ecommerceGenerated = `${OSS_MUBAN}/ecommerce-carousel-generated.png`;
const ecommerceSlide4 = `${OSS_MUBAN}/slide-4.png`;
const ecommerceSlide5 = `${OSS_MUBAN}/slide-5.png`;
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace";
import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel";
@@ -72,6 +68,7 @@ interface CloneResult {
id: string;
src: string;
label: string;
type?: "image" | "video";
}
interface CloneSavedSetting {
@@ -597,15 +594,12 @@ const tryOnModelOptions = {
ethnicity: ["欧美白人", "亚洲人", "拉美裔", "非洲裔"],
body: ["标准", "高挑", "微胖", "运动"],
};
const sampleResults = [ecommerceSlide4, ecommerceGenerated, ecommerceSlide5];
const productSetAssets = {
main: "https://xiuxiu-pro.meitudata.com/poster/6e3eebacad8d5e47e1896ee7d54827bc.png?imageView2/2/w/800/format/webp/q/80/ignore-error/1",
scene: "https://xiuxiu-pro.meitudata.com/poster/21225fc86b28d9e4d85636483c67408e.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
model: "https://xiuxiu-pro.meitudata.com/poster/4b8e6d1bd0996be52822dd1fac73cffd.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
detail: "https://xiuxiu-pro.meitudata.com/poster/29dd195a450ee5a7f7451ded6680e969.png?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
selling: "https://xiuxiu-pro.meitudata.com/poster/66bdef541b67588e8db2a03b39dc815b.jpg?imageView2/2/w/400/format/webp/q/80/ignore-error/1",
hosting: "https://xiuxiu-pro-new.meitudata.com/poster/50c17a98c77fac4d0523c8cbdf0d33ca.jpg?imageView2/2/format/webp/q/80/ignore-error/1",
};
const sampleResults = [
ossAssets.ecommerce.slides.slide4,
ossAssets.ecommerce.generated,
ossAssets.ecommerce.slides.slide5,
];
const productSetAssets = ossAssets.ecommerce.productSet;
const productSetPreviewCards = [
{ id: "main", label: "01 主图 (白底/合规)", src: productSetAssets.main },
{ id: "scene", label: "02 场景展示", src: productSetAssets.scene },
@@ -613,21 +607,7 @@ const productSetPreviewCards = [
{ id: "detail", label: "04 细节说明", src: productSetAssets.detail },
{ id: "selling", label: "05 卖点详解", src: productSetAssets.selling },
];
const tryOnAssets = {
dressA: "https://xiuxiu-pro-new.meitudata.com/poster/133ca2d6c13bac6cfaa11fa29a155551.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
dressB: "https://xiuxiu-pro-new.meitudata.com/poster/a661006820e888d9df13023075096e94.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelWoman: "https://xiuxiu-pro-new.meitudata.com/poster/f806c6afaf6f38f634c156c5b6058201.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelMan: "https://xiuxiu-pro-new.meitudata.com/poster/8c26503c67dc695e25e420e48caf4cde.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
modelAsian: "https://xiuxiu-pro-new.meitudata.com/poster/0f2a7c92707312ec74647d66f15a6ef9.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
tryA: "https://xiuxiu-pro-new.meitudata.com/poster/7f77e0866f05ff723959e1f48830713c.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
tryB: "https://xiuxiu-pro-new.meitudata.com/poster/0b951004eabcdd7cae595dfdb4c7f8c3.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacket: "https://xiuxiu-pro-new.meitudata.com/poster/fdbf10b4c92af5b1986444cdd9affaa5.png?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
jacketResultA: "https://xiuxiu-pro-new.meitudata.com/poster/b1152bb292323b87696dd2f6e518e818.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
jacketResultB: "https://xiuxiu-pro-new.meitudata.com/poster/1c1e757702108fef92d85be0c2802c01.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hat: "https://xiuxiu-pro-new.meitudata.com/poster/278af735b076ab812888802d3e3db0b8.jpg?imageView2/2/w/280/format/webp/q/80/ignore-error/1",
hatResultA: "https://xiuxiu-pro-new.meitudata.com/poster/a3ba241b7aa6060869b096d3f10e5db4.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
hatResultB: "https://xiuxiu-pro-new.meitudata.com/poster/01ed1ae80a187c70c682bb6d0ec6fa68.jpg?imageView2/2/w/345/format/webp/q/80/ignore-error/1",
};
const tryOnAssets = ossAssets.ecommerce.tryOn;
const tryOnCards = [
{
@@ -672,18 +652,7 @@ const detailModules = [
const defaultDetailModuleIds: string[] = [];
const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"];
const cloneDetailModules = detailModules;
const detailAssets = {
productA: "https://xiuxiu-pro.meitudata.com/poster/182676711565ee98e20cf92d766d1643.png?imageView2/2/format/webp/q/80/ignore-error/1",
productB: "https://xiuxiu-pro.meitudata.com/poster/ba6312cbc3a32ceb8966f9ea20b9ee9c.png?imageView2/2/format/webp/q/80/ignore-error/1",
productC: "https://xiuxiu-pro.meitudata.com/poster/7ee5753a3141fa12cda155126c8225d3.png?imageView2/2/format/webp/q/80/ignore-error/1",
longPage: "https://xiuxiu-pro.meitudata.com/poster/19ef313484fc87c9bdd3cd52ce2a5947.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridA: "https://xiuxiu-pro.meitudata.com/poster/e74e8d920ac0f87020f90457d42a7153.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridB: "https://xiuxiu-pro.meitudata.com/poster/1652064f17c5c2b32ce287244b505c15.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridC: "https://xiuxiu-pro.meitudata.com/poster/dd8abace327edf61d8a8e2d7db42cfbe.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridD: "https://xiuxiu-pro.meitudata.com/poster/7dc397f1cb76a35f7f0ed3c3ce78ba81.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridE: "https://xiuxiu-pro.meitudata.com/poster/1199bd8b968a5162752e1ee2b093d315.png?imageView2/2/format/webp/q/80/ignore-error/1",
gridF: "https://xiuxiu-pro.meitudata.com/poster/7a8cdb3693418df9915741960f8f5aa8.png?imageView2/2/format/webp/q/80/ignore-error/1",
};
const detailAssets = ossAssets.ecommerce.detail;
const detailProductSamples = [detailAssets.productA, detailAssets.productB, detailAssets.productC];
const detailGridSamples = [detailAssets.gridA, detailAssets.gridB, detailAssets.gridC, detailAssets.gridD, detailAssets.gridE, detailAssets.gridF];
@@ -866,13 +835,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? videoOutfitVideoFile && videoOutfitRefFile
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
: productImages.length > 0) && status !== "generating";
const canGenerateTryOn = garmentImages.length > 0 && tryOnStatus !== "generating" && tryOnStatus !== "modeling";
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle = {
const cloneVideoDurationStyle: CSSProperties = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
@@ -1487,7 +1456,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pMarket: string,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneImageItem[]) => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
statusFn?.("generating");
try {
+26 -16
View File
@@ -1,18 +1,28 @@
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png";
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg";
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg";
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png";
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif";
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp";
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp";
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp";
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp";
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp";
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png";
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png";
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png";
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png";
import { ossAssets } from "../../data/ossAssets";
const [
moreTemplateSlide1,
moreTemplateSlide2,
moreTemplateSlide3,
moreTemplateSlide4,
moreTemplateSlide5,
] = ossAssets.ecommerce.templateSlides;
const [
ecommerceHeroSlide1,
ecommerceHeroSlide2,
ecommerceHeroSlide3,
ecommerceHeroSlide4,
ecommerceHeroSlide5,
] = ossAssets.ecommerce.heroSlides;
const [
ecommerceCarouselImage1,
ecommerceCarouselImage2,
ecommerceCarouselImage3,
ecommerceCarouselImage4,
ecommerceCarouselImage5,
ecommerceCarouselImage6,
] = ossAssets.ecommerce.templateCases;
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
export interface TemplateCase {
title: string;
@@ -124,6 +134,6 @@ export const templateCases: TemplateCase[] = [
title: "促销卖点组合图",
category: "详情图",
summary: "把成分、规格、卖点拆成清晰的详情页模块。",
imageUrl: "https://picsum.photos/id/1080/900/620",
imageUrl: ecommerceCarouselImage6,
},
];
@@ -7,15 +7,18 @@ import {
ReloadOutlined,
SettingOutlined,
} 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";
type CloneOutputKey = string;
type CloneSetCountKey = string;
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
type CloneSetCountKey = "selling" | "white" | "scene";
type CloneModelPanelTab = "scene" | "model";
type CloneReferenceMode = "upload" | "link";
type CloneReplicateLevelKey = string;
type CloneVideoQualityKey = string;
type CloneReplicateLevelKey = "style" | "high";
type CloneVideoQualityKey = "standard" | "high" | "ultra";
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
interface CloneImageItem {
id: string;
@@ -24,7 +27,7 @@ interface CloneImageItem {
}
interface CloneBasicSelectItem {
key: string;
key: CloneBasicSelectKey;
label: string;
value: string;
options: string[];
@@ -32,7 +35,7 @@ interface CloneBasicSelectItem {
}
interface CloneModelSelectItem {
key: string;
key: CloneModelSelectKey;
label: string;
value: string;
options: string[];
@@ -76,7 +79,7 @@ interface EcommerceClonePanelProps {
cloneOutput: CloneOutputKey;
cloneOutputOptions: CloneOutputOption[];
cloneBasicSelects: CloneBasicSelectItem[];
openCloneBasicSelect: string | null;
openCloneBasicSelect: CloneBasicSelectKey | null;
cloneReferenceMode: CloneReferenceMode;
cloneReferenceImages: CloneImageItem[];
maxCloneReferenceImages: number;
@@ -94,7 +97,7 @@ interface EcommerceClonePanelProps {
selectedCloneModelScenes: string[];
cloneModelCustomScene: string;
cloneModelSelects: CloneModelSelectItem[];
openCloneModelSelect: string | null;
openCloneModelSelect: CloneModelSelectKey | null;
cloneModelSelectDropUp: boolean;
cloneModelAppearance: string;
cloneVideoQuality: CloneVideoQualityKey;
@@ -102,27 +105,27 @@ interface EcommerceClonePanelProps {
cloneVideoDuration: number;
cloneVideoDurationMin: number;
cloneVideoDurationMax: number;
cloneVideoDurationStyle: { [key: string]: number | string };
cloneVideoDurationStyle: CSSProperties;
cloneVideoSmart: boolean;
canGenerate: boolean;
status: string;
lastFailedActionRef: MutableRefObject<(() => void) | null>;
setIsProductUploadDragging: (value: boolean) => void;
handleProductDrop: (event: DragEvent<HTMLElement>) => void;
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
removeProductImage: (id: string) => void;
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
handleCloneOutputChange: (value: CloneOutputKey) => void;
setOpenCloneBasicSelect: (value: string | null) => void;
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
clearCloneSetCountHold: () => void;
toggleCloneDetailModule: (id: string) => void;
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
toggleCloneModelScene: (scene: string) => void;
setCloneModelCustomScene: (value: string) => void;
setOpenCloneModelSelect: (value: string | null) => void;
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
setCloneModelSelectDropUp: (value: boolean) => void;
setCloneModelAppearance: (value: string) => void;
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
@@ -1,12 +1,14 @@
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
import type { ChangeEvent, DragEvent, RefObject } from "react";
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
interface EcommerceSetPanelProps {
setInputRef: RefObject<HTMLInputElement>;
setImages: Array<{ id: string; src: string; name: string }>;
isSetUploadDragging: boolean;
productSetOutputOptions: Array<{ key: string; label: string }>;
productSetOutput: string;
productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }>;
productSetOutput: ProductSetOutputKey;
platformOptions: string[];
marketOptions: string[];
productSetLanguageOptions: string[];
@@ -16,10 +18,10 @@ interface EcommerceSetPanelProps {
productSetLanguage: string;
productSetRatio: string;
setIsSetUploadDragging: (value: boolean) => void;
handleSetDrop: (event: DragEvent<HTMLElement>) => void;
handleSetDrop: (event: DragEvent<HTMLButtonElement>) => void;
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
removeSetImage: (id: string) => void;
handleProductSetOutputChange: (value: string) => void;
handleProductSetOutputChange: (value: ProductSetOutputKey) => void;
handleProductSetPlatformChange: (value: string) => void;
handleProductSetMarketChange: (value: string) => void;
setProductSetLanguage: (value: string) => void;
+8 -8
View File
@@ -10,6 +10,7 @@ import {
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
import { ossAssets } from "../../data/ossAssets";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
@@ -24,13 +25,12 @@ function ScrollEntrance({ children, className, ...rest }: { children: React.Reac
);
}
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
const heroImage1 = `${OSS_MUBAN}/hero-1.png`;
const heroImage2 = `${OSS_MUBAN}/hero-2.png`;
const heroImage3 = `${OSS_MUBAN}/hero-3.png`;
const featureEcommerceImage = `${OSS_MUBAN}/feature-ecommerce.jpg`;
const featureScriptImage = `${OSS_MUBAN}/feature-script.jpg`;
const featureTokenImage = `${OSS_MUBAN}/feature-token.jpg`;
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const {
ecommerce: featureEcommerceImage,
script: featureScriptImage,
token: featureTokenImage,
} = ossAssets.home.features;
interface HomePageProps {
onOpenGenerate: () => void;
@@ -42,7 +42,7 @@ interface HomePageProps {
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
}
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/hero-bg.mp4";
const HOME_BACKGROUND_VIDEO = ossAssets.home.backgroundVideo;
const HOME_CAROUSEL_IMAGES = [
{ imageUrl: heroImage1, title: "灵感生成" },
+8 -4
View File
@@ -1,9 +1,13 @@
import { ToolOutlined } from "@ant-design/icons";
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
import toolImageBefore from "../../assets/toolbox/牛仔.png";
import toolImageAfter from "../../assets/toolbox/西装.png";
import watermarkBefore from "../../assets/toolbox/去水印前.png";
import watermarkAfter from "../../assets/toolbox/去水印后.png";
import { ossAssets } from "../../data/ossAssets";
const {
imageBefore: toolImageBefore,
imageAfter: toolImageAfter,
watermarkBefore,
watermarkAfter,
} = ossAssets.toolbox;
interface ToolboxSectionProps {
onSelectView: (view: WebViewKey) => void;
+3 -2
View File
@@ -20,6 +20,7 @@ import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import { keyServerClient } from "../../api/keyServerClient";
import { isServerRequestError } from "../../api/serverConnection";
import { ossAssets } from "../../data/ossAssets";
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
import type { SavedAssetItem } from "../assets/localAssetStore";
@@ -44,8 +45,8 @@ type ProfilePanel = "works" | "projects" | "assets" | "community";
type AccountPanel = "credits" | "tasks";
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
const AUTH_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
const AUTH_SHOWCASE_VIDEO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test5.mp4";
const AUTH_LOGO_URL = ossAssets.brand.logo;
const AUTH_SHOWCASE_VIDEO_URL = ossAssets.auth.showcaseVideo;
function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`;
+22 -4
View File
@@ -1,5 +1,6 @@
import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "@ant-design/icons";
import { useState, type FormEvent } from "react";
import { useEffect, useState, type FormEvent } from "react";
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
import { reportClient, type ReportInput } from "../../api/reportClient";
type SubmitState = "idle" | "loading" | "success" | "error";
@@ -31,6 +32,7 @@ function ReportPage() {
const [contactPhone, setContactPhone] = useState("");
const [submitState, setSubmitState] = useState<SubmitState>("idle");
const [errorMsg, setErrorMsg] = useState("");
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
const canSubmit =
submitState !== "loading" && reportType !== "" && title.trim() !== "" && description.trim() !== "";
@@ -48,6 +50,22 @@ function ReportPage() {
setErrorMsg("");
};
useEffect(() => {
let cancelled = false;
publicConfigClient
.get()
.then((config) => {
if (!cancelled) setPublicConfig(config);
})
.catch(() => {
if (!cancelled) setPublicConfig({});
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) return;
@@ -85,9 +103,9 @@ function ReportPage() {
</header>
<div className="report-contact-strip">
<span><MailOutlined /> {import.meta.env.VITE_REPORT_EMAIL || "support@omniai.com"}</span>
<span><PhoneOutlined /> {import.meta.env.VITE_REPORT_PHONE || "请在环境变量配置客服电话"}</span>
<span>{import.meta.env.VITE_ICP_RECORD || "ICP备案信息待配置"}</span>
<span><MailOutlined /> {publicConfig.contactEmail || "由服务器配置"}</span>
<span><PhoneOutlined /> {publicConfig.contactPhone || "由服务器配置"}</span>
<span>{publicConfig.icpRecord || "由服务器配置"}</span>
</div>
{submitState === "success" ? (
-5
View File
@@ -999,11 +999,6 @@ function WorkbenchPage({
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
if (status.status === "completed") {
import("../../utils/generationNotifier").then((m) =>
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
);
}
return;
}
+3 -3
View File
@@ -19,18 +19,18 @@ export interface UseGenerationStatusReturn {
export function useGenerationStatus(): UseGenerationStatusReturn {
const [status, setStatus] = useState<GenStatus>("idle");
const [error, setError] = useState<string | null>(null);
const abortRef = useRef({ current: false });
const abortRef = useRef(false);
const start = useCallback(() => {
setStatus("generating");
setError(null);
abortRef.current = { current: false };
abortRef.current = false;
}, []);
const succeed = useCallback(() => setStatus("done"), []);
const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []);
const reset = useCallback(() => { setStatus("idle"); setError(null); }, []);
const cancel = useCallback(() => { abortRef.current.current = true; }, []);
const cancel = useCallback(() => { abortRef.current = true; }, []);
return {
status, error, abortRef, start, succeed, fail, reset, cancel,
+143 -69
View File
@@ -2831,10 +2831,10 @@
.product-clone-page[data-tool="clone"] .clone-ai-preview-showcase {
display: grid;
grid-template-columns: minmax(210px, 300px) 54px minmax(330px, 560px);
grid-template-columns: minmax(260px, 380px) 54px minmax(400px, 1fr);
align-items: center;
gap: 20px;
width: min(100%, 960px);
gap: 28px;
width: min(100%, 1120px);
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result,
@@ -2842,24 +2842,26 @@
position: relative;
overflow: hidden;
border: 1px solid #2c3038;
border-radius: 14px;
border-radius: 16px;
background: #1b1d23;
padding: 0;
cursor: pointer;
transition:
border-color 160ms ease,
transform 160ms ease;
border-color 200ms ease,
transform 200ms ease,
box-shadow 200ms ease;
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result:hover,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover {
border-color: #00ff88;
transform: translateY(-1px);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 255, 136, 0.1), 0 2px 8px rgba(0, 0, 0, 0.3);
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result:active,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:active {
transform: scale(0.98);
transform: scale(0.97);
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result img,
@@ -2868,39 +2870,46 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 300ms ease;
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result:hover img,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:hover img {
transform: scale(1.03);
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result {
height: 360px;
height: 440px;
}
.product-clone-page[data-tool="clone"] .clone-ai-result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
gap: 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button {
height: 172px;
height: 210px;
}
.product-clone-page[data-tool="clone"] .clone-ai-result-grid button:first-child {
grid-column: 1 / -1;
height: 190px;
height: 240px;
}
.product-clone-page[data-tool="clone"] .clone-ai-main-result span,
.product-clone-page[data-tool="clone"] .clone-ai-result-grid span {
position: absolute;
left: 11px;
top: 11px;
max-width: calc(100% - 22px);
left: 12px;
top: 12px;
max-width: calc(100% - 24px);
overflow: hidden;
border: 1px solid #303540;
border: 1px solid rgba(48, 53, 64, 0.6);
border-radius: 999px;
background: #15171c;
background: rgba(21, 23, 28, 0.85);
backdrop-filter: blur(8px);
color: #d8deed;
padding: 6px 10px;
padding: 7px 13px;
font-size: 12px;
font-weight: 900;
text-overflow: ellipsis;
@@ -3793,8 +3802,8 @@
.product-clone-thumb-row,
.product-clone-ref-grid {
display: grid;
gap: 8px;
margin-top: 10px;
gap: 10px;
margin-top: 12px;
}
.product-clone-thumb-row {
@@ -3805,7 +3814,13 @@
.product-clone-ref-grid img {
width: 100%;
object-fit: cover;
border-radius: 8px;
border-radius: 10px;
transition: transform 250ms ease;
}
.product-clone-thumb-row img:hover,
.product-clone-ref-grid img:hover {
transform: scale(1.03);
}
.product-clone-thumb-row img {
@@ -3989,12 +4004,12 @@
display: grid;
align-content: center;
justify-items: center;
gap: 34px;
gap: 36px;
min-width: 0;
min-height: 0;
overflow: auto;
background: #f5f6f8;
padding: 42px;
padding: 48px;
}
.product-clone-preview__headline {
@@ -4018,21 +4033,29 @@
.product-clone-demo-board {
position: relative;
display: grid;
grid-template-columns: minmax(260px, 340px) 44px minmax(300px, 360px);
grid-template-columns: minmax(300px, 400px) 48px minmax(340px, 420px);
align-items: center;
gap: 30px;
width: min(100%, 780px);
border-radius: 22px;
gap: 34px;
width: min(100%, 920px);
border-radius: 24px;
background: #ffffff;
padding: 30px;
padding: 34px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.product-clone-source-card,
.product-clone-result-stack figure {
position: relative;
overflow: hidden;
border-radius: 14px;
border-radius: 16px;
background: #f2f4f7;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-clone-source-card:hover,
.product-clone-result-stack figure:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.06);
}
.product-clone-source-card img,
@@ -4041,16 +4064,23 @@
width: 100%;
aspect-ratio: 1.55;
object-fit: cover;
transition: transform 300ms ease;
}
.product-clone-source-card:hover img,
.product-clone-result-stack figure:hover img {
transform: scale(1.02);
}
.product-clone-source-card span,
.product-clone-result-stack figcaption {
position: absolute;
top: 10px;
right: 10px;
top: 12px;
right: 12px;
border-radius: 999px;
background: #ffffff;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(6px);
padding: 7px 13px;
color: #111827;
font-size: 12px;
font-weight: 800;
@@ -4099,7 +4129,7 @@
.product-clone-result-stack {
display: grid;
gap: 10px;
gap: 12px;
}
.product-clone-result-stack figure {
@@ -4849,19 +4879,25 @@
.product-set-demo-board {
display: grid;
grid-template-columns: 336px 40px 338px;
grid-template-columns: minmax(300px, 420px) 44px minmax(340px, 1fr);
align-items: center;
gap: 24px;
width: min(100%, 802px);
min-height: 336px;
gap: 28px;
width: min(100%, 960px);
min-height: 380px;
}
.product-set-demo-board figure {
position: relative;
overflow: hidden;
margin: 0;
border-radius: 12px;
border-radius: 14px;
background: #ffffff;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-set-demo-board figure:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
.product-set-demo-board img {
@@ -4869,6 +4905,11 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 300ms ease;
}
.product-set-demo-board figure:hover img {
transform: scale(1.03);
}
.product-set-demo-board figcaption {
@@ -4878,35 +4919,43 @@
max-width: calc(100% - 24px);
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(6px);
color: #111827;
padding: 6px 10px;
font-size: 12px;
padding: 7px 14px;
font-size: 13px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.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 {
width: 40px;
height: 24px;
width: 44px;
height: 26px;
border-radius: 999px;
background: #b8c3d1;
background: linear-gradient(90deg, #b8c3d1, #d7dde6);
clip-path: polygon(0 28%, 58% 28%, 58% 0, 100% 50%, 58% 100%, 58% 72%, 0 72%);
}
.product-set-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
gap: 14px;
}
.product-set-card-grid figure {
height: 162px;
height: 184px;
}
.product-set-generated-note {
@@ -5360,13 +5409,13 @@
}
.product-clone-page[data-tool="set"] .product-set-demo-board {
grid-template-columns: minmax(360px, 486px) 44px minmax(360px, 486px);
gap: 28px;
width: min(100%, 1150px);
min-height: 576px;
grid-template-columns: minmax(380px, 1fr) 48px minmax(380px, 1fr);
gap: 32px;
width: min(100%, 1200px);
min-height: 620px;
border-radius: 32px;
background: #ffffff;
padding: 37px 30px;
padding: 40px 34px;
}
.product-clone-page[data-tool="set"] .product-set-demo-board figure {
@@ -5376,16 +5425,16 @@
}
.product-clone-page[data-tool="set"] .product-set-main-card {
height: 502px;
height: 540px;
background: #ffffff;
}
.product-clone-page[data-tool="set"] .product-set-card-grid {
gap: 18px;
gap: 20px;
}
.product-clone-page[data-tool="set"] .product-set-card-grid figure {
height: 242px;
height: 260px;
}
.product-clone-page[data-tool="set"] .product-set-demo-board figcaption {
@@ -6840,26 +6889,37 @@
.product-try-on-generated {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
width: min(100%, 766px);
border-radius: 18px;
gap: 14px;
width: min(100%, 820px);
border-radius: 20px;
background: #ffffff;
padding: 14px;
padding: 16px;
}
.product-try-on-generated figure {
position: relative;
overflow: hidden;
margin: 0;
border-radius: 12px;
border-radius: 14px;
background: #edf1f6;
transition: transform 250ms ease, box-shadow 250ms ease;
}
.product-try-on-generated figure:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
}
.product-try-on-generated img {
display: block;
width: 100%;
height: 180px;
height: 200px;
object-fit: cover;
transition: transform 300ms ease;
}
.product-try-on-generated figure:hover img {
transform: scale(1.03);
}
.product-try-on-generated figcaption {
@@ -6867,8 +6927,9 @@
left: 10px;
bottom: 10px;
border-radius: 999px;
background: #ffffff;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(6px);
padding: 6px 12px;
color: #111827;
font-size: 12px;
font-weight: 800;
@@ -7524,15 +7585,27 @@
overflow: hidden;
margin: 0;
border: 1px solid #dfe5ee;
border-radius: 10px;
border-radius: 12px;
background: #f5f6f8;
aspect-ratio: 1;
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
}
.product-set-thumb:hover {
border-color: #c6cdd8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.product-set-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 250ms ease;
}
.product-set-thumb:hover img {
transform: scale(1.04);
}
.product-set-thumb button {
@@ -7540,12 +7613,13 @@
top: 6px;
right: 6px;
display: grid;
width: 24px;
height: 24px;
width: 26px;
height: 26px;
place-items: center;
border: 1px solid #dfe5ee;
border: 1px solid rgba(223, 229, 238, 0.7);
border-radius: 999px;
background: #ffffff;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
color: #111827;
cursor: pointer;
transition:
+70 -29
View File
@@ -596,14 +596,27 @@ textarea.image-workbench-prompt {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--fg-dim);
gap: 12px;
width: 100%;
height: 100%;
color: var(--fg-muted);
font-size: 14px;
}
.image-workbench-empty .anticon {
font-size: 32px;
opacity: 0.5;
font-size: 40px;
opacity: 0.35;
}
.image-workbench-empty strong {
font-size: 18px;
color: var(--fg-body, #eee);
}
.image-workbench-empty span {
max-width: 320px;
text-align: center;
line-height: 1.5;
}
.image-workbench-empty--button {
@@ -824,22 +837,24 @@ textarea.image-workbench-prompt {
.image-workbench-panel--right .image-workbench-result-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 8px;
margin-top: 8px;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 10px;
margin-top: 10px;
}
.image-workbench-result-thumb {
display: block;
overflow: hidden;
border-radius: 6px;
border-radius: 8px;
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
aspect-ratio: 1;
transition: border-color 0.15s;
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
}
.image-workbench-result-thumb:hover {
border-color: var(--accent, #2dd4bf);
transform: scale(1.04);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.image-workbench-result-thumb img {
@@ -1598,30 +1613,30 @@ textarea.image-workbench-prompt {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
gap: 14px;
width: 100%;
height: 100%;
color: var(--fg-muted);
}
.image-workbench-generating strong {
font-size: 20px;
font-size: 22px;
color: var(--fg-default);
}
.image-workbench-progress-bar {
width: 320px;
height: 8px;
border-radius: 4px;
width: min(420px, 80%);
height: 10px;
border-radius: 5px;
background: var(--bg-inset);
overflow: hidden;
}
.image-workbench-progress-fill {
height: 100%;
border-radius: 4px;
background: var(--accent);
transition: width 0.3s ease;
border-radius: 5px;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
transition: width 0.35s ease;
}
.image-workbench-cancel {
@@ -1642,30 +1657,30 @@ textarea.image-workbench-prompt {
}
.image-workbench-result-grid {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
align-content: center;
justify-items: center;
width: 100%;
height: 100%;
margin: 0;
padding: 24px;
padding: 32px;
overflow-y: auto;
gap: 16px;
gap: 20px;
}
.image-workbench-result-item {
display: block;
border-radius: var(--radius-sm);
border-radius: var(--radius-md, 12px);
overflow: hidden;
border: 1px solid var(--border-weak);
transition: border-color 0.15s, box-shadow 0.15s;
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
}
.image-workbench-result-item:hover {
border-color: var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(var(--accent-rgb, 45, 212, 191), 0.1);
transform: translateY(-2px);
}
.image-workbench-result-item img {
@@ -1674,20 +1689,26 @@ textarea.image-workbench-prompt {
height: auto;
object-fit: contain;
background: var(--bg-inset);
transition: transform 300ms ease;
}
.image-workbench-result-item:hover img {
transform: scale(1.02);
}
.image-workbench-result-card {
display: grid;
min-width: 0;
width: min(100%, 500px);
width: 100%;
max-width: 560px;
align-content: start;
gap: 12px;
gap: 14px;
}
.image-workbench-result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
gap: 10px;
}
.image-workbench-result-actions button {
@@ -1735,3 +1756,23 @@ textarea.image-workbench-prompt {
opacity: 0.6;
cursor: not-allowed;
}
/* Result card entrance animation */
@keyframes image-workbench-result-enter {
from {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.image-workbench-result-card {
animation: image-workbench-result-enter 0.4s ease-out both;
}
.image-workbench-result-card:nth-child(2) { animation-delay: 0.08s; }
.image-workbench-result-card:nth-child(3) { animation-delay: 0.16s; }
.image-workbench-result-card:nth-child(4) { animation-delay: 0.24s; }
+10 -9
View File
@@ -1,5 +1,4 @@
const ERROR_REPORT_ENDPOINT = "/api/client-errors";
const CLIENT_ERROR_REPORTING_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_REPORTING === "1";
interface ErrorReport {
message: string;
@@ -28,12 +27,16 @@ function getSessionId(): string | undefined {
function flush() {
if (reportQueue.length === 0) return;
const batch = reportQueue.splice(0, 10);
const baseUrl = import.meta.env.VITE_API_BASE_URL || "";
const url = `${baseUrl}${ERROR_REPORT_ENDPOINT}`;
const token = localStorage.getItem("omniai:token") || sessionStorage.getItem("omniai:token") || "";
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
navigator.sendBeacon?.(url, new Blob([JSON.stringify({ errors: batch })], { type: "application/json" }));
const payload = new Blob([JSON.stringify({ errors: batch })], { type: "application/json" });
if (navigator.sendBeacon?.(ERROR_REPORT_ENDPOINT, payload)) return;
void fetch(ERROR_REPORT_ENDPOINT, {
method: "POST",
body: JSON.stringify({ errors: batch }),
headers: { "Content-Type": "application/json" },
credentials: "include",
keepalive: true,
}).catch(() => {});
}
function scheduleFlush() {
@@ -45,8 +48,6 @@ function scheduleFlush() {
}
export function reportError(error: unknown, source: ErrorReport["source"] = "manual") {
if (!CLIENT_ERROR_REPORTING_ENABLED) return;
const err = error instanceof Error ? error : new Error(String(error));
const report: ErrorReport = {
message: err.message,
+3 -11
View File
@@ -1,11 +1,8 @@
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import { defineConfig, loadEnv } from "vite";
import { defineConfig } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
export default defineConfig(() => ({
plugins: [
react(),
compression({ algorithms: ["gzip", "brotliCompress"], threshold: 1024 }),
@@ -15,10 +12,6 @@ export default defineConfig(({ mode }) => {
host: "127.0.0.1",
proxy: {
"/api": {
target: env.VITE_DEV_PROXY || "https://omniai.net.cn",
changeOrigin: true,
},
"/dashscope-api": {
target: "https://omniai.net.cn",
changeOrigin: true,
},
@@ -49,5 +42,4 @@ export default defineConfig(({ mode }) => {
},
},
},
};
});
}));