diff --git a/.env.example b/.env.example index 44008b1..4ad78fa 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file +# 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. diff --git a/.gitignore b/.gitignore index 39ebdc3..ddc64a0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ node_modules/ Thumbs.db .vscode/ .idea/ +.claude/ +tmp/ *.swp *.swo -coverage/ \ No newline at end of file +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4f0e1b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/package.json b/package.json index efb8eb3..9cdec5e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/check-governance.mjs b/scripts/check-governance.mjs new file mode 100644 index 0000000..efd2246 --- /dev/null +++ b/scripts/check-governance.mjs @@ -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."); diff --git a/scripts/check-style-governance.mjs b/scripts/check-style-governance.mjs new file mode 100644 index 0000000..3f5d301 --- /dev/null +++ b/scripts/check-style-governance.mjs @@ -0,0 +1 @@ +import "./check-governance.mjs"; diff --git a/src/App.tsx b/src/App.tsx index d42a8f4..2e1d5fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); diff --git a/src/api/modelCapabilitiesClient.ts b/src/api/modelCapabilitiesClient.ts index 0ce1f8a..85b0f8b 100644 --- a/src/api/modelCapabilitiesClient.ts +++ b/src/api/modelCapabilitiesClient.ts @@ -67,7 +67,6 @@ let modelCapabilitiesRouteMissing = false; export const modelCapabilitiesClient = { async get(name = "web-model-capabilities"): Promise { - if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities(); if (modelCapabilitiesRouteMissing) return createFallbackCapabilities(); let payload: unknown; diff --git a/src/api/publicConfigClient.ts b/src/api/publicConfigClient.ts new file mode 100644 index 0000000..6398ede --- /dev/null +++ b/src/api/publicConfigClient.ts @@ -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, 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 { + if (cachedPublicConfig) return cachedPublicConfig; + if (publicConfigRouteMissing) return {}; + + try { + const payload = await serverRequest("public/config/profile?name=web-public-config"); + cachedPublicConfig = normalizePublicConfig(payload); + return cachedPublicConfig; + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + publicConfigRouteMissing = true; + return {}; + } + throw error; + } + }, +}; diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index 6b72197..d1302fa 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -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(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(response, "Request failed"); diff --git a/src/assets/ecommerce-carousel-generated.png b/src/assets/ecommerce-carousel-generated.png deleted file mode 100644 index e5d107d..0000000 Binary files a/src/assets/ecommerce-carousel-generated.png and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-1.webp b/src/assets/ecommerce-hero-carousel/slide-1.webp deleted file mode 100644 index 3af02be..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-1.webp and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-2.webp b/src/assets/ecommerce-hero-carousel/slide-2.webp deleted file mode 100644 index 869d351..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-2.webp and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-3.webp b/src/assets/ecommerce-hero-carousel/slide-3.webp deleted file mode 100644 index 9f9f010..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-3.webp and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-4.png b/src/assets/ecommerce-hero-carousel/slide-4.png deleted file mode 100644 index 192dd55..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-4.png and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-4.webp b/src/assets/ecommerce-hero-carousel/slide-4.webp deleted file mode 100644 index 78eb0b7..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-4.webp and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-5.png b/src/assets/ecommerce-hero-carousel/slide-5.png deleted file mode 100644 index a9c55ac..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-5.png and /dev/null differ diff --git a/src/assets/ecommerce-hero-carousel/slide-5.webp b/src/assets/ecommerce-hero-carousel/slide-5.webp deleted file mode 100644 index 03cbff6..0000000 Binary files a/src/assets/ecommerce-hero-carousel/slide-5.webp and /dev/null differ diff --git a/src/assets/home-features/feature-ecommerce.jpg b/src/assets/home-features/feature-ecommerce.jpg deleted file mode 100644 index 70ce725..0000000 Binary files a/src/assets/home-features/feature-ecommerce.jpg and /dev/null differ diff --git a/src/assets/home-features/feature-script.jpg b/src/assets/home-features/feature-script.jpg deleted file mode 100644 index 1fe454a..0000000 Binary files a/src/assets/home-features/feature-script.jpg and /dev/null differ diff --git a/src/assets/home-features/feature-token.jpg b/src/assets/home-features/feature-token.jpg deleted file mode 100644 index b1ca5f7..0000000 Binary files a/src/assets/home-features/feature-token.jpg and /dev/null differ diff --git a/src/assets/home-features/home-ecommerce-template-1.png b/src/assets/home-features/home-ecommerce-template-1.png deleted file mode 100644 index aa4091f..0000000 Binary files a/src/assets/home-features/home-ecommerce-template-1.png and /dev/null differ diff --git a/src/assets/home-features/home-ecommerce-template-2.png b/src/assets/home-features/home-ecommerce-template-2.png deleted file mode 100644 index 3ff5165..0000000 Binary files a/src/assets/home-features/home-ecommerce-template-2.png and /dev/null differ diff --git a/src/assets/home-features/home-ecommerce-template-3.png b/src/assets/home-features/home-ecommerce-template-3.png deleted file mode 100644 index c620c4d..0000000 Binary files a/src/assets/home-features/home-ecommerce-template-3.png and /dev/null differ diff --git a/src/assets/logo.png b/src/assets/logo.png deleted file mode 100644 index dc4eea8..0000000 Binary files a/src/assets/logo.png and /dev/null differ diff --git a/src/assets/more-template-carousel/slide-1.jpg b/src/assets/more-template-carousel/slide-1.jpg deleted file mode 100644 index 76679ec..0000000 Binary files a/src/assets/more-template-carousel/slide-1.jpg and /dev/null differ diff --git a/src/assets/more-template-carousel/slide-2.jpg b/src/assets/more-template-carousel/slide-2.jpg deleted file mode 100644 index 231aab8..0000000 Binary files a/src/assets/more-template-carousel/slide-2.jpg and /dev/null differ diff --git a/src/assets/more-template-carousel/slide-3.jpg b/src/assets/more-template-carousel/slide-3.jpg deleted file mode 100644 index 920fbae..0000000 Binary files a/src/assets/more-template-carousel/slide-3.jpg and /dev/null differ diff --git a/src/assets/more-template-carousel/slide-4.png b/src/assets/more-template-carousel/slide-4.png deleted file mode 100644 index a0d78dc..0000000 Binary files a/src/assets/more-template-carousel/slide-4.png and /dev/null differ diff --git a/src/assets/more-template-carousel/slide-5.gif b/src/assets/more-template-carousel/slide-5.gif deleted file mode 100644 index e64151c..0000000 Binary files a/src/assets/more-template-carousel/slide-5.gif and /dev/null differ diff --git a/src/assets/screenshot-white.png b/src/assets/screenshot-white.png deleted file mode 100644 index cbf4dd7..0000000 Binary files a/src/assets/screenshot-white.png and /dev/null differ diff --git a/src/assets/toolbox/toolbox_img_0.png b/src/assets/toolbox/toolbox_img_0.png deleted file mode 100644 index c620c4d..0000000 Binary files a/src/assets/toolbox/toolbox_img_0.png and /dev/null differ diff --git a/src/assets/toolbox/toolbox_img_1.png b/src/assets/toolbox/toolbox_img_1.png deleted file mode 100644 index 3ff5165..0000000 Binary files a/src/assets/toolbox/toolbox_img_1.png and /dev/null differ diff --git a/src/assets/toolbox/去水印前.png b/src/assets/toolbox/去水印前.png deleted file mode 100644 index f734abf..0000000 Binary files a/src/assets/toolbox/去水印前.png and /dev/null differ diff --git a/src/assets/toolbox/去水印后.png b/src/assets/toolbox/去水印后.png deleted file mode 100644 index 0648e8f..0000000 Binary files a/src/assets/toolbox/去水印后.png and /dev/null differ diff --git a/src/assets/toolbox/牛仔.png b/src/assets/toolbox/牛仔.png deleted file mode 100644 index c620c4d..0000000 Binary files a/src/assets/toolbox/牛仔.png and /dev/null differ diff --git a/src/assets/toolbox/西装.png b/src/assets/toolbox/西装.png deleted file mode 100644 index 3ff5165..0000000 Binary files a/src/assets/toolbox/西装.png and /dev/null differ diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 626e2f3..a03cf8a 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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(null); const [openSubmenuKey, setOpenSubmenuKey] = useState(null); + const [publicConfig, setPublicConfig] = useState({}); const prevActiveViewRef = useRef(activeView); const [navJustActivated, setNavJustActivated] = useState(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({
备案信息
-
苏ICP备2026021747号-1
+
{publicConfig.icpRecord || "由服务器配置"}
公司地址
-
江苏省南京市江北新区扬子江数字视听产业园9栋A楼501
+
{publicConfig.companyAddress || "由服务器配置"}
联系电话
-
15155073618
+
{publicConfig.contactPhone || "由服务器配置"}
setInfoOpen(false)}>用户协议 @@ -407,7 +421,7 @@ function AppShell({
{usage.videoUsed}
- {import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"} + {session?.source === "server" ? "服务器会话" : "预览会话"}
- {CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? : null} + {session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
diff --git a/src/data/ossAssets.ts b/src/data/ossAssets.ts new file mode 100644 index 0000000..221e4db --- /dev/null +++ b/src/data/ossAssets.ts @@ -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; diff --git a/src/data/workflows.ts b/src/data/workflows.ts index c6c4dcc..3387ab9 100644 --- a/src/data/workflows.ts +++ b/src/data/workflows.ts @@ -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(), }, }, diff --git a/src/features/community/CommunityPage.tsx b/src/features/community/CommunityPage.tsx index 4770c90..bcc738c 100644 --- a/src/features/community/CommunityPage.tsx +++ b/src/features/community/CommunityPage.tsx @@ -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); diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 13c864b..3463b9e 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -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 => { statusFn?.("generating"); try { diff --git a/src/features/ecommerce/ecommerceTemplates.ts b/src/features/ecommerce/ecommerceTemplates.ts index 45ee5e9..6f96ea9 100644 --- a/src/features/ecommerce/ecommerceTemplates.ts +++ b/src/features/ecommerce/ecommerceTemplates.ts @@ -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, }, ]; diff --git a/src/features/ecommerce/panels/EcommerceClonePanel.tsx b/src/features/ecommerce/panels/EcommerceClonePanel.tsx index a2f2f35..a732d87 100644 --- a/src/features/ecommerce/panels/EcommerceClonePanel.tsx +++ b/src/features/ecommerce/panels/EcommerceClonePanel.tsx @@ -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) => void; + handleProductDrop: (event: DragEvent) => void; removeProductImage: (id: string) => void; handleProductUpload: (event: ChangeEvent) => void; handleCloneOutputChange: (value: CloneOutputKey) => void; - setOpenCloneBasicSelect: (value: string | null) => void; + setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void; setCloneReferenceMode: (value: CloneReferenceMode) => void; handleCloneReferenceUpload: (event: ChangeEvent) => 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; diff --git a/src/features/ecommerce/panels/EcommerceSetPanel.tsx b/src/features/ecommerce/panels/EcommerceSetPanel.tsx index 71925a1..4baa053 100644 --- a/src/features/ecommerce/panels/EcommerceSetPanel.tsx +++ b/src/features/ecommerce/panels/EcommerceSetPanel.tsx @@ -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; 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) => void; + handleSetDrop: (event: DragEvent) => void; handleSetUpload: (event: ChangeEvent) => 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; diff --git a/src/features/home/HomePage.tsx b/src/features/home/HomePage.tsx index 366ccc8..2883532 100644 --- a/src/features/home/HomePage.tsx +++ b/src/features/home/HomePage.tsx @@ -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: "灵感生成" }, diff --git a/src/features/home/ToolboxSection.tsx b/src/features/home/ToolboxSection.tsx index 7a7fbca..44f1402 100644 --- a/src/features/home/ToolboxSection.tsx +++ b/src/features/home/ToolboxSection.tsx @@ -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; diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx index 49d1067..06e8ad8 100644 --- a/src/features/profile/ProfilePage.tsx +++ b/src/features/profile/ProfilePage.tsx @@ -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}`; diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 0004248..5a88fd3 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -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("idle"); const [errorMsg, setErrorMsg] = useState(""); + const [publicConfig, setPublicConfig] = useState({}); 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() {
- {import.meta.env.VITE_REPORT_EMAIL || "support@omniai.com"} - {import.meta.env.VITE_REPORT_PHONE || "请在环境变量配置客服电话"} - {import.meta.env.VITE_ICP_RECORD || "ICP备案信息待配置"} + {publicConfig.contactEmail || "由服务器配置"} + {publicConfig.contactPhone || "由服务器配置"} + {publicConfig.icpRecord || "由服务器配置"}
{submitState === "success" ? ( diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index 138f295..ecc444f 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -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; } diff --git a/src/hooks/useGenerationStatus.ts b/src/hooks/useGenerationStatus.ts index 31be09a..7d502cb 100644 --- a/src/hooks/useGenerationStatus.ts +++ b/src/hooks/useGenerationStatus.ts @@ -19,18 +19,18 @@ export interface UseGenerationStatusReturn { export function useGenerationStatus(): UseGenerationStatusReturn { const [status, setStatus] = useState("idle"); const [error, setError] = useState(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, diff --git a/src/styles/pages/ecommerce.css b/src/styles/pages/ecommerce.css index 0d9f2ae..2d414b5 100644 --- a/src/styles/pages/ecommerce.css +++ b/src/styles/pages/ecommerce.css @@ -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: diff --git a/src/styles/pages/image-workbench.css b/src/styles/pages/image-workbench.css index 1497b92..20948ea 100644 --- a/src/styles/pages/image-workbench.css +++ b/src/styles/pages/image-workbench.css @@ -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; } diff --git a/src/utils/errorReporting.ts b/src/utils/errorReporting.ts index 3f2ec52..c637853 100644 --- a/src/utils/errorReporting.ts +++ b/src/utils/errorReporting.ts @@ -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 = { "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, diff --git a/vite.config.ts b/vite.config.ts index eabda6e..5dea423 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 }) => { }, }, }, - }; -}); \ No newline at end of file + }));